Merge pull request #76 from openqj/fix-handler-500
fix: 请求处理未捕获异常回 500 JSON,不再断连变 nginx 502
This commit is contained in:
2 files changed
+142
-4
No files matched your search
+34
-4
@@ -959,6 +959,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
# 发头即算已响应:之后 wfile 写失败(客户端已断)也不应再
|
||||
# 尝试回 500——响应一旦开始就不能再发第二个状态行。
|
||||
self._responded = True
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_html(self, status, html_body):
|
||||
@@ -967,6 +970,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self._responded = True
|
||||
self.wfile.write(body)
|
||||
|
||||
def _read_body(self):
|
||||
@@ -1006,18 +1010,43 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
|
||||
# -- 入口 ----------------------------------------------------------
|
||||
def do_GET(self):
|
||||
self._dispatch("GET")
|
||||
self._dispatch_safe("GET")
|
||||
|
||||
def do_POST(self):
|
||||
self._dispatch("POST")
|
||||
self._dispatch_safe("POST")
|
||||
|
||||
def do_DELETE(self):
|
||||
self._dispatch("DELETE")
|
||||
self._dispatch_safe("DELETE")
|
||||
|
||||
def do_PATCH(self):
|
||||
# PATCH 之前没有入口(BaseHTTPRequestHandler 默认回 501),
|
||||
# 项目补仓库是第一个 PATCH 路由;与其它方法一样走 _dispatch。
|
||||
self._dispatch("PATCH")
|
||||
self._dispatch_safe("PATCH")
|
||||
|
||||
def _dispatch_safe(self, method):
|
||||
"""分发兜底:_dispatch 里任何未捕获异常都回 500 JSON,而不是
|
||||
让请求线程直接断连(2026-10-27 死锁事件就是这么变成前端
|
||||
“HTTP 502”的:异常逃出 handle_one_request 后连接无响应,
|
||||
nginx 只能报 502,客户端拿不到任何可读错误)。
|
||||
|
||||
500 体只写固定 internal_error,不把堆栈泄漏给客户端;
|
||||
堆栈走 _server_log.exception 进服务端日志。已开始响应
|
||||
(SSE 流、_send_json/_send_html 已发头)时不再二次发送,
|
||||
只记日志——响应一旦开始就不能再发第二个状态行。
|
||||
_responded 每请求重置(同一连接 keep-alive 复用 handler
|
||||
对象时不串请求)。
|
||||
"""
|
||||
self._responded = False
|
||||
try:
|
||||
self._dispatch(method)
|
||||
except Exception:
|
||||
_server_log.exception("unhandled error in %s %s",
|
||||
method, self.path)
|
||||
if not self._responded:
|
||||
try:
|
||||
self._send_json(500, {"error": "internal_error"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _dispatch(self, method):
|
||||
parsed = urlparse(self.path)
|
||||
@@ -2461,6 +2490,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
return
|
||||
self._responded = True
|
||||
# SSE 无 Content-Length,done 后靠关连接界定流结束;注意
|
||||
# send_header("Connection", "keep-alive") 会把
|
||||
# close_connection 改回 False,所以必须在 end_headers 之后
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""请求处理 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()
|
||||
Reference in new issue
Block a user