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:
2026-08-16 06:07:29 -04:00
parent 484900d300
commit 0edb9d397a
2 changed files with 149 additions and 18 deletions
+39 -18
View File
@@ -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]: