feat: keep the gateway's words on every non-2xx chat branch
Issue #10 Task 3: five branches each built their own message, so adding the summary would have meant five copies. Table-driven classification composes it in one place instead, and the 429 split still parses the untruncated body - reading the summary would demote an oversized insufficient_quota to a plain rate limit and stop force_open.
This commit is contained in:
@@ -691,6 +691,116 @@ class TestErrorTranslation:
|
||||
await _complete(_transport_for(handler), _source())
|
||||
|
||||
|
||||
# issue #10 原文给出的真实响应体(一字不改): 关键在于 code 收尾
|
||||
_REJECT_BODY = (
|
||||
'{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal '
|
||||
'and cannot be opened","type":"invalid_request_error","param":"",'
|
||||
'"code":"invalid_parameter_error"}}'
|
||||
)
|
||||
|
||||
|
||||
class TestErrorBodyRetention:
|
||||
"""issue #10: 网关说了什么必须活着离开翻译层——message 与字段各留一份。"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "exc"),
|
||||
[
|
||||
(400, RequestRejectedError),
|
||||
(401, SourceDeadError),
|
||||
(403, SourceDeadError),
|
||||
(404, RequestRejectedError),
|
||||
(500, TransientError),
|
||||
(503, TransientError),
|
||||
],
|
||||
)
|
||||
async def test_every_non_2xx_branch_keeps_the_body(self, status, exc):
|
||||
def handler(request):
|
||||
return httpx.Response(status, content=_REJECT_BODY.encode())
|
||||
|
||||
with pytest.raises(exc) as ei:
|
||||
await _complete(_transport_for(handler), _source())
|
||||
assert ei.value.body_text == _REJECT_BODY
|
||||
# message 与字段共用同一份串: 遥测里看到的与下游 catch 到的不得打架
|
||||
assert str(ei.value).endswith(f" | {_REJECT_BODY}")
|
||||
assert "invalid_parameter_error" in str(ei.value)
|
||||
|
||||
async def test_rate_limited_429_keeps_body_and_retry_after(self):
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
429,
|
||||
content=b'{"error":{"message":"per-minute cap 3"}}',
|
||||
headers={"retry-after": "2.5"},
|
||||
)
|
||||
|
||||
with pytest.raises(TransientError) as ei:
|
||||
await _complete(_transport_for(handler), _source())
|
||||
assert "per-minute cap 3" in str(ei.value)
|
||||
assert ei.value.retry_after_s == 2.5 # 摘要不得干扰既有解析
|
||||
|
||||
async def test_insufficient_quota_429_keeps_body(self):
|
||||
body = json.dumps(
|
||||
{"error": {"type": "insufficient_quota", "message": "daily budget spent"}}
|
||||
)
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(429, content=body.encode())
|
||||
|
||||
with pytest.raises(SourceDeadError) as ei:
|
||||
await _complete(_transport_for(handler), _source())
|
||||
assert "daily budget spent" in str(ei.value)
|
||||
|
||||
async def test_oversized_insufficient_quota_still_classified_dead(self):
|
||||
"""实现红线: 类型判定必须读**原文**。
|
||||
|
||||
摘要会破坏 JSON 结构,若改用摘要解析,超长 body 的配额耗尽将退化成普通
|
||||
限速——配额已耗尽的源不再 force_open,一个诊断改进就变成了治理 bug。
|
||||
"""
|
||||
body = json.dumps(
|
||||
{"padding": "P" * 4000, "error": {"type": "insufficient_quota", "message": "spent"}}
|
||||
)
|
||||
assert len(body) > 2048
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(429, content=body.encode())
|
||||
|
||||
with pytest.raises(SourceDeadError):
|
||||
await _complete(_transport_for(handler), _source())
|
||||
|
||||
async def test_empty_body_leaves_no_dangling_separator(self):
|
||||
def handler(request):
|
||||
return httpx.Response(400, content=b"")
|
||||
|
||||
with pytest.raises(RequestRejectedError) as ei:
|
||||
await _complete(_transport_for(handler), _source())
|
||||
assert str(ei.value) == "qwen_1 请求被拒: 400"
|
||||
assert ei.value.body_text == ""
|
||||
|
||||
@pytest.mark.parametrize("body", [b"<html>gateway down</html>", b"\xff\xfe not utf-8"])
|
||||
async def test_non_json_and_non_utf8_bodies_do_not_explode(self, body):
|
||||
def handler(request):
|
||||
return httpx.Response(400, content=body)
|
||||
|
||||
with pytest.raises(RequestRejectedError) as ei:
|
||||
await _complete(_transport_for(handler), _source())
|
||||
assert ei.value.status_code == 400 # 分类不受 body 形态影响
|
||||
|
||||
async def test_non_stream_path_keeps_the_body(self):
|
||||
def handler(request):
|
||||
return httpx.Response(400, content=_REJECT_BODY.encode())
|
||||
|
||||
with pytest.raises(RequestRejectedError) as ei:
|
||||
await _complete(_transport_for(handler), _source(), stream=False)
|
||||
assert ei.value.body_text == _REJECT_BODY
|
||||
|
||||
async def test_embedding_path_keeps_the_body(self):
|
||||
def handler(request):
|
||||
return httpx.Response(400, content=_REJECT_BODY.encode())
|
||||
|
||||
with pytest.raises(RequestRejectedError) as ei:
|
||||
await _transport_for(handler).embed(texts=["hi"], source=_source(), call_id="cid-embed")
|
||||
assert ei.value.body_text == _REJECT_BODY
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
async def test_aclose_idempotent(self):
|
||||
def handler(request):
|
||||
|
||||
Reference in New Issue
Block a user