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:
@@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any
|
||||
import httpx
|
||||
|
||||
from polygateway.errors import (
|
||||
PolyGatewayError,
|
||||
RequestRejectedError,
|
||||
ResultInvalidError,
|
||||
SourceDeadError,
|
||||
@@ -30,6 +31,7 @@ from polygateway.providers import (
|
||||
resolve_thinking,
|
||||
)
|
||||
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
|
||||
from polygateway.transports._http_errors import compose_message, summarize_body
|
||||
from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -107,40 +109,59 @@ def _parse_retry_after(raw: str | None) -> float | None:
|
||||
return seconds if seconds > 0 else None
|
||||
|
||||
|
||||
def _translate_429(source: SourceConfig, body_text: str, headers: Mapping[str, str]) -> Exception:
|
||||
def _translate_429(
|
||||
source: SourceConfig, body_text: str, headers: Mapping[str, str], ctx: dict[str, Any]
|
||||
) -> Exception:
|
||||
"""429 细分。`body_text` 必须是**未截断的原文**——`ctx["body_text"]` 是摘要,
|
||||
头尾保留会破坏 JSON 结构,拿它解析会让超长 body 的配额耗尽退化成普通限速
|
||||
(该源不再 force_open),把一个诊断改进变成治理 bug(issue #10 实现红线)。
|
||||
"""
|
||||
try:
|
||||
err_type = json.loads(body_text).get("error", {}).get("type", "")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
err_type = ""
|
||||
summary = ctx["body_text"]
|
||||
if err_type == "insufficient_quota":
|
||||
return SourceDeadError(
|
||||
f"{source.name} 配额耗尽(insufficient_quota)",
|
||||
source_name=source.name,
|
||||
status_code=429,
|
||||
operation="chat",
|
||||
compose_message(f"{source.name} 配额耗尽(insufficient_quota)", summary), **ctx
|
||||
)
|
||||
return TransientError(
|
||||
f"{source.name} 限速: 429",
|
||||
compose_message(f"{source.name} 限速: 429", summary),
|
||||
retry_after_s=_parse_retry_after(headers.get("retry-after")),
|
||||
source_name=source.name,
|
||||
status_code=429,
|
||||
operation="chat",
|
||||
**ctx,
|
||||
)
|
||||
|
||||
|
||||
def _classify(status: int) -> tuple[type[PolyGatewayError], str]:
|
||||
"""状态码 → (错误类, message 标签);映射与 ARCH §6.2 逐条相同,本次零变更。"""
|
||||
if status in (401, 403):
|
||||
return SourceDeadError, "凭据失效/欠费"
|
||||
if status == 400:
|
||||
return RequestRejectedError, "请求被拒"
|
||||
if status >= 500:
|
||||
return TransientError, "瞬时错误"
|
||||
return RequestRejectedError, "客户端错误"
|
||||
|
||||
|
||||
def _status_to_error(
|
||||
source: SourceConfig, status: int, body_text: str, headers: Mapping[str, str]
|
||||
) -> Exception:
|
||||
ctx: dict[str, Any] = {"source_name": source.name, "status_code": status, "operation": "chat"}
|
||||
if status in (401, 403):
|
||||
return SourceDeadError(f"{source.name} 凭据失效/欠费: {status}", **ctx)
|
||||
if status == 400:
|
||||
return RequestRejectedError(f"{source.name} 请求被拒: 400", **ctx)
|
||||
"""非 2xx → 领域错误,**全部分支**携带响应体摘要(issue #10)。
|
||||
|
||||
摘要只算一次,message 与 `body_text` 共用同一份串: 两份不同长度会让"遥测里
|
||||
看到的"与"下游 catch 到的"对不上,排查时反而多一层困惑。
|
||||
"""
|
||||
summary = summarize_body(body_text)
|
||||
ctx: dict[str, Any] = {
|
||||
"source_name": source.name,
|
||||
"status_code": status,
|
||||
"operation": "chat",
|
||||
"body_text": summary,
|
||||
}
|
||||
if status == 429:
|
||||
return _translate_429(source, body_text, headers)
|
||||
if status >= 500:
|
||||
return TransientError(f"{source.name} 瞬时错误: {status}", **ctx)
|
||||
return RequestRejectedError(f"{source.name} 客户端错误: {status}", **ctx)
|
||||
return _translate_429(source, body_text, headers, ctx)
|
||||
cls, label = _classify(status)
|
||||
return cls(compose_message(f"{source.name} {label}: {status}", summary), **ctx)
|
||||
|
||||
|
||||
def _strip_think(content: str) -> tuple[str, str]:
|
||||
|
||||
@@ -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