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。
109 lines
4.0 KiB
Python
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()
|