Files
codex-app-server/cloud/tests/test_handler_500.py
host df8c493281 fix: 请求处理未捕获异常回 500 JSON,不再断连变 nginx 502
2026-10-27 死锁事件的次生问题:_dispatch 里未捕获异常逃出
handle_one_request 后连接无响应,前端只看到 nginx 502、拿不到
任何可读错误;死锁本身修掉后,同类内部异常仍会以 502 露出来。

兜底(_dispatch_safe,四个 do_* 方法统一走它):
- 任何异常先记 _server_log.exception(堆栈进服务端日志),
  再回固定 {"error": "internal_error"},不泄漏堆栈给客户端;
- _send_json/_send_html/SSE 发头后即标记已响应,之后异常
  只记日志、不二次发送(响应一旦开始不能再发状态行);
- _responded 每请求重置,同一连接 keep-alive 复用不串请求。

单测:异常请求得 500 JSON(非断连)+ 服务端记日志;
同一连接上 500 之后再请求仍 200。
2026-10-27 10:56:08 +08:00

109 lines
4.0 KiB
Python

"""请求处理 500 兜底单测。
_dispatch 里未捕获异常必须回 500 JSON,而不是让请求线程直接
断连(2026-10-27 死锁事件就是这么变成前端“HTTP 502”的)。
同时验证:500 之后同一连接仍可用(_responded 每请求重置),
服务端记了异常日志。
"""
import http.client
import json
import os
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from unittest import mock
try:
from cloud.auth import AuthStore
from cloud.server import CloudControlPlaneHandler, create_server
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.server import CloudControlPlaneHandler, create_server
class Handler500TestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, self.project = self._req(
"POST", "/projects", {"name": "owner/repo"}, self.owner_key)
self.assertEqual(status, 201, self.project)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None, conn=None):
own = conn is None
if own:
conn = http.client.HTTPConnection(
"127.0.0.1", self.port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
raw = resp.read()
if own:
conn.close()
try:
parsed = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
parsed = None
return resp.status, parsed
def test_exception_returns_500_json_not_dropped(self):
# _dispatch 整体抛错:客户端必须拿到 500 + 固定 error 体,
# 而不是连接断开(旧行为:无响应,nginx 转 502)。
with mock.patch.object(
CloudControlPlaneHandler, "_dispatch",
side_effect=RuntimeError("boom")), \
mock.patch("cloud.server._server_log") as log:
status, body = self._req(
"GET", "/projects", key=self.owner_key)
self.assertEqual(status, 500)
self.assertEqual(body, {"error": "internal_error"})
log.exception.assert_called_once()
def test_connection_usable_after_500(self):
# 同一连接上:先 500,再正常请求须 200——_responded
# 必须每请求重置,不能把连接钉死在已响应状态。
conn = http.client.HTTPConnection(
"127.0.0.1", self.port, timeout=10)
try:
with mock.patch.object(
CloudControlPlaneHandler, "_dispatch",
side_effect=RuntimeError("boom")), \
mock.patch("cloud.server._server_log"):
status, body = self._req(
"GET", "/projects", key=self.owner_key, conn=conn)
self.assertEqual(status, 500)
self.assertEqual(body, {"error": "internal_error"})
status, body = self._req(
"GET", "/projects", key=self.owner_key, conn=conn)
self.assertEqual(status, 200)
self.assertIsInstance(body, dict)
finally:
conn.close()