Skip to content

Commit 690e0ef

Browse files
authored
Merge pull request #84 from ThreeFish-AI/vk/b2ca-fix-500-200
fix(error-handling): 修复流式路径裸 500 异常并增强错误日志
2 parents e82f117 + 459fc24 commit 690e0ef

4 files changed

Lines changed: 212 additions & 2 deletions

File tree

src/coding/proxy/routing/executor.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from __future__ import annotations
88

9+
import json
910
import logging
1011
import time
1112
from typing import Any, AsyncIterator
@@ -40,6 +41,34 @@
4041

4142
logger = logging.getLogger(__name__)
4243

44+
45+
def _log_http_error_detail(tier_name: str, exc: Exception, *, is_stream: bool = False) -> None:
46+
"""记录 HTTP 错误的详细信息(状态码 / 响应体摘要 / 异常类型).
47+
48+
替代原先单行 ``logger.warning("Tier %s stream failed: %s", ...)``,
49+
在非 200 响应时输出更丰富的诊断上下文,便于跟踪上游故障根因。
50+
"""
51+
detail_parts = [f"Tier {tier_name} {'stream' if is_stream else 'message'} failed:"]
52+
detail_parts.append(f" exc_type={type(exc).__name__}")
53+
if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None:
54+
resp = exc.response
55+
detail_parts.append(f" status={resp.status_code}")
56+
body_preview = (resp.text[:300] if resp.text else "(empty)") if resp.content else "(no content)"
57+
detail_parts.append(f" response_body={body_preview}")
58+
# 尝试提取 error type / message
59+
try:
60+
payload = resp.json() if resp.content else None
61+
except Exception:
62+
payload = None
63+
if isinstance(payload, dict):
64+
err = payload.get("error", {})
65+
if isinstance(err, dict):
66+
detail_parts.append(f" error_type={err.get('type', 'N/A')}")
67+
detail_parts.append(f" error_msg={err.get('message', 'N/A')[:200]}")
68+
else:
69+
detail_parts.append(f" message={str(exc)[:300]}")
70+
logger.warning("\n".join(detail_parts))
71+
4372
# tier.name → 上游 Vendor 协议标签映射(用于 token 用量日志标注)
4473
_VENDOR_PROTOCOL_LABEL_MAP: dict[str, str] = {
4574
"anthropic": "Anthropic",
@@ -143,12 +172,23 @@ async def execute_stream(
143172
raise
144173

145174
except (httpx.HTTPStatusError, httpx.TimeoutException, httpx.ConnectError, httpx.ReadError) as exc:
146-
logger.warning("Tier %s stream failed: %s", tier.name, exc)
175+
_log_http_error_detail(tier.name, exc, is_stream=True)
147176
should_continue, failed_tier_name, last_exc = await self._handle_http_error(tier, exc, is_last, failed_tier_name, last_exc, is_stream=True)
148177
if should_continue:
149178
continue
150179
if is_last:
151180
raise
181+
except Exception as exc:
182+
logger.error(
183+
"Tier %s stream unexpected error: %s: %s",
184+
tier.name, type(exc).__name__, exc,
185+
exc_info=True,
186+
)
187+
tier.record_failure()
188+
failed_tier_name = tier.name
189+
if not is_last:
190+
continue
191+
raise
152192

153193
if last_exc:
154194
raise last_exc
@@ -229,12 +269,23 @@ async def execute_message(
229269
continue
230270

231271
except (httpx.TimeoutException, httpx.ConnectError, httpx.ReadError) as exc:
232-
logger.warning("Tier %s connection error: %s", tier.name, exc)
272+
_log_http_error_detail(tier.name, exc, is_stream=False)
233273
tier.record_failure()
234274
failed_tier_name = tier.name
235275
if is_last:
236276
raise
237277
continue
278+
except Exception as exc:
279+
logger.error(
280+
"Tier %s message unexpected error: %s: %s",
281+
tier.name, type(exc).__name__, exc,
282+
exc_info=True,
283+
)
284+
tier.record_failure()
285+
failed_tier_name = tier.name
286+
if not is_last:
287+
continue
288+
raise
238289

239290
if incompatible_reasons:
240291
raise NoCompatibleVendorError("当前请求包含仅客户端/MCP 可安全承接的能力,未找到兼容供应商", reasons=incompatible_reasons)

src/coding/proxy/server/routes.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ async def _stream_proxy(router: Any, body: dict, headers: dict) -> Any:
4343
except httpx.HTTPStatusError as exc:
4444
error_type, message = extract_stream_http_error(exc)
4545
yield stream_error_event(error_type, message)
46+
except Exception as exc:
47+
logger.error(
48+
"_stream_proxy 未预期异常: %s: %s",
49+
type(exc).__name__, exc,
50+
exc_info=True,
51+
)
52+
yield stream_error_event(
53+
"api_error",
54+
f"内部错误: {type(exc).__name__}: {exc}",
55+
)
4656

4757

4858
def register_core_routes(app: Any, router: Any) -> None:
@@ -76,6 +86,13 @@ async def messages(request: Request) -> Response:
7686
return json_error_response(503, error_type="authentication_error", message=str(exc))
7787
except (httpx.TimeoutException, httpx.ConnectError, httpx.ReadError) as exc:
7888
return json_error_response(502, error_type="api_error", message=f"上游不可达: {exc}")
89+
except Exception as exc:
90+
logger.error(
91+
"messages() 非流式路径未预期异常: %s: %s",
92+
type(exc).__name__, exc,
93+
exc_info=True,
94+
)
95+
return json_error_response(500, error_type="api_error", message=f"内部错误: {type(exc).__name__}")
7996
return Response(content=resp.raw_body or b"{}", status_code=resp.status_code, media_type="application/json")
8097

8198
@app.post("/v1/messages/count_tokens")

tests/test_app_routes.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,76 @@ async def failing_route_message(body, headers):
317317
assert "socket closed" in data["error"]["message"]
318318

319319

320+
def test_stream_unexpected_exception_returns_sse_error_not_500():
321+
"""流式路径的未预期异常应返回 SSE error event(HTTP 200 + event: error),而非框架级 500.
322+
323+
验证 catch-all Exception 处理器将未知异常转换为结构化 SSE 错误事件,
324+
客户端可正常解析错误信息而非收到裸 HTTP 500。
325+
"""
326+
app = create_app(ProxyConfig(
327+
primary={"enabled": False},
328+
fallback={"enabled": True},
329+
database={"path": "/tmp/test-coding-proxy-unexpected.db"},
330+
))
331+
332+
async def failing_route_stream(body, headers):
333+
raise ValueError("unexpected parsing error")
334+
yield # pragma: no cover
335+
336+
app.state.router.route_stream = failing_route_stream
337+
338+
with TestClient(app) as client:
339+
with client.stream(
340+
"POST",
341+
"/v1/messages",
342+
json={
343+
"model": "claude-sonnet-4-20250514",
344+
"messages": [{"role": "user", "content": "Hi"}],
345+
"stream": True,
346+
},
347+
) as resp:
348+
body = b"".join(resp.iter_bytes()).decode()
349+
350+
# 关键断言:必须是 200(SSE 流正常关闭),不能是 500
351+
assert resp.status_code == 200
352+
assert "event: error" in body
353+
assert '"type": "api_error"' in body
354+
# 确认包含异常类型信息,便于调试定位
355+
assert "ValueError" in body or "unexpected" in body.lower()
356+
357+
358+
def test_non_stream_unexpected_exception_returns_500_json_not_raw_500():
359+
"""非流式路径的未预期异常应返回结构化 JSON 错误(含异常类型),而非框架级原始 500.
360+
361+
验证 catch-all Exception 处理器将未知异常转换为 JSON 格式的 api_error 响应,
362+
服务端日志记录完整堆栈(exc_info=True),客户端可从响应体获取错误类型。
363+
"""
364+
app = create_app(ProxyConfig(
365+
primary={"enabled": False},
366+
fallback={"enabled": True},
367+
database={"path": "/tmp/test-coding-proxy-nonstream-unexpected.db"},
368+
))
369+
370+
async def failing_route_message(body, headers):
371+
raise RuntimeError("internal state corruption")
372+
373+
app.state.router.route_message = failing_route_message
374+
375+
with TestClient(app) as client:
376+
resp = client.post(
377+
"/v1/messages",
378+
json={
379+
"model": "claude-sonnet-4-20250514",
380+
"messages": [{"role": "user", "content": "Hi"}],
381+
},
382+
)
383+
384+
assert resp.status_code == 500
385+
data = resp.json()
386+
assert data["error"]["type"] == "api_error"
387+
assert "RuntimeError" in data["error"]["message"]
388+
389+
320390
def test_messages_normalizes_vendor_tool_blocks_before_routing():
321391
"""入口应先规范化 server_tool_use,再交给高优先级 tier."""
322392
app = create_app(ProxyConfig(

tests/test_router_executor.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,33 @@ async def test_non_last_tier_continues_on_connect_error(self):
346346
resp = await exec_inst.execute_message({"model": "test"}, {})
347347
assert resp.status_code == 200
348348

349+
@pytest.mark.asyncio
350+
async def test_unexpected_exception_on_last_tier_propagates(self):
351+
"""最后一层非流式未预期异常应向上传播."""
352+
vendor = _mock_vendor()
353+
vendor.send_message.side_effect = KeyError("missing config key")
354+
exec_inst = _executor([_make_tier(vendor)])
355+
356+
with pytest.raises(KeyError, match="missing config key"):
357+
await exec_inst.execute_message({"model": "test"}, {})
358+
359+
@pytest.mark.asyncio
360+
async def test_unexpected_exception_on_non_last_tier_continues(self):
361+
"""非最后一层非流式未预期异常应触发故障转移."""
362+
bad = _mock_vendor("bad")
363+
bad.send_message.side_effect = RuntimeError("internal state corruption")
364+
365+
good = _mock_vendor("good")
366+
good_resp = VendorResponse(
367+
status_code=200, raw_body=b'{}',
368+
usage=UsageInfo(input_tokens=1, output_tokens=1),
369+
)
370+
good.send_message = AsyncMock(return_value=good_resp)
371+
372+
exec_inst = _executor([_make_tier(bad), _make_tier(good)])
373+
resp = await exec_inst.execute_message({"model": "test"}, {})
374+
assert resp.status_code == 200
375+
349376

350377
# ── execute_stream 测试 ──────────────────────────────────
351378

@@ -410,6 +437,51 @@ async def _raise_http(*a, **kw):
410437
async for _ in exec_inst.execute_stream({"model": "test"}, {}):
411438
pass
412439

440+
@pytest.mark.asyncio
441+
async def test_stream_unexpected_exception_continues_to_next_tier(self):
442+
"""非最后一层流式未预期异常应触发故障转移到下一层."""
443+
bad_vendor = _mock_vendor("bad")
444+
445+
async def _raise_unexpected(*a, **kw):
446+
raise ValueError("upstream returned garbage")
447+
yield # noqa: PYS101
448+
449+
bad_vendor.send_message_stream = _raise_unexpected
450+
451+
good_vendor = _mock_vendor("good")
452+
453+
async def _good_stream(*a, **kw):
454+
yield b'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n'
455+
456+
good_vendor.send_message_stream = _good_stream
457+
458+
exec_inst = _executor([
459+
_make_tier(bad_vendor),
460+
_make_tier(good_vendor),
461+
])
462+
463+
collected = []
464+
async for chunk, name in exec_inst.execute_stream({"model": "test"}, {}):
465+
collected.append((chunk, name))
466+
assert len(collected) > 0
467+
assert name == "good"
468+
469+
@pytest.mark.asyncio
470+
async def test_stream_unexpected_exception_on_last_tier_propagates(self):
471+
"""最后一层流式未预期异常应向上传播(由 _stream_proxy 接管)."""
472+
vendor = _mock_vendor()
473+
474+
async def _raise_unexpected(*a, **kw):
475+
raise RuntimeError("stream corrupted")
476+
yield # noqa: PYS101
477+
478+
vendor.send_message_stream = _raise_unexpected
479+
exec_inst = _executor([_make_tier(vendor)])
480+
481+
with pytest.raises(RuntimeError, match="stream corrupted"):
482+
async for _ in exec_inst.execute_stream({"model": "test"}, {}):
483+
pass # noqa: PLC0107
484+
413485

414486
# ── 错误处理测试 ─────────────────────────────────────────
415487

0 commit comments

Comments
 (0)