feat: let every gateway error carry what the gateway said

Issue #10 Task 1: a rejected call's reason had nowhere to live. The
field goes on the base class because these errors all come from one HTTP
response - which class it is and what the peer said are orthogonal.
This commit is contained in:
2026-08-16 06:01:38 -04:00
parent 1489aab95d
commit e302247022
2 changed files with 70 additions and 5 deletions
+32 -2
View File
@@ -35,7 +35,26 @@ SOURCE_REASONS = frozenset(
class PolyGatewayError(Exception): class PolyGatewayError(Exception):
"""库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。""" """库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。
`body_text` 是**非 2xx 响应体的摘要**——网关拒绝这次调用时说的话(issue #10)。
它与 `ResultInvalidError.raw_text` 是两回事,严禁混用:
============== ==================================================
``body_text`` **非 2xx** 的 HTTP 错误响应体: 对方**拒绝**的理由
``raw_text`` **2xx** 但内容不可解析时的模型输出原文
============== ==================================================
加在基类而非某个子类,是因为这些错误全部由同一个 HTTP 响应翻译而来——
"对方说了什么""它属于哪一类"正交。scope 级错误(`GatewayUnavailableError`
一族)继承到的恒空值不是噪音,而是"没有单一响应体可言"的如实表达。
**内容已由 transport 层截断**(`transports/_http_errors.summarize_body`),
且可能包含网关对请求的回显——库不做脱敏: 它不知道下游哪些字段敏感,
猜测式脱敏只会同时丢掉诊断价值与安全性。
本字段是**旁路数据**,不参与任何治理判定(重试/换源/熔断计数/限流结算)。
"""
def __init__( def __init__(
self, self,
@@ -44,11 +63,13 @@ class PolyGatewayError(Exception):
source_name: str | None = None, source_name: str | None = None,
status_code: int | None = None, status_code: int | None = None,
operation: str | None = None, operation: str | None = None,
body_text: str = "",
) -> None: ) -> None:
super().__init__(message) super().__init__(message)
self.source_name = source_name self.source_name = source_name
self.status_code = status_code self.status_code = status_code
self.operation = operation self.operation = operation
self.body_text = body_text
class TransientError(PolyGatewayError): class TransientError(PolyGatewayError):
@@ -64,7 +85,16 @@ class SourceDeadError(PolyGatewayError):
class RequestRejectedError(PolyGatewayError): class RequestRejectedError(PolyGatewayError):
"""请求被拒(400/坏输入): 不重试不换源,直接上抛。""" """请求被拒(400/坏输入): 不重试不换源,直接上抛。
**经中转部署时请注意**(issue #10 下游实测): 第三方 API 中转服务自身抖动
时也会回 400,从状态码上与供应商说"你的输入有问题"无法区分。下游曾观测到
同一份字节(sha256 一致)重发 15 次全部成功,且失败那次 `prompt_tokens=0`、
耗时远低于任何成功调用——请求在推理开始前就被挡了。本库仍按确定性失败处理
(对直连供应商而言重试只会白烧配额),批处理场景的下游宜自备兜底分类;
`body_text` 即为此提供判据: 中转抖动的响应体与供应商的 `invalid_request_error`
形态不同。
"""
class ResultInvalidError(PolyGatewayError): class ResultInvalidError(PolyGatewayError):
+38 -3
View File
@@ -34,6 +34,43 @@ class TestBaseShape:
assert TransientError("429", retry_after_s=2.5).retry_after_s == 2.5 assert TransientError("429", retry_after_s=2.5).retry_after_s == 2.5
class TestBodyText:
"""issue #10: 非 2xx 的响应体摘要必须有承载处,否则拒绝理由事后不可查。"""
@pytest.mark.parametrize(
"cls", (PolyGatewayError, TransientError, SourceDeadError, RequestRejectedError)
)
def test_defaults_empty_and_accepts_summary(self, cls):
assert cls("boom").body_text == ""
assert cls("boom", body_text='{"error":{"code":"bad"}}').body_text == (
'{"error":{"code":"bad"}}'
)
def test_result_invalid_keeps_both_fields_apart(self):
"""`body_text`(非 2xx 的拒绝理由)与 `raw_text`(2xx 的不可解析输出)不得混用。"""
exc = ResultInvalidError("bad json", raw_text="{oops", body_text="")
assert exc.raw_text == "{oops"
assert exc.body_text == ""
@pytest.mark.parametrize(
"exc",
(
AllSourcesExhausted(scope="llm", reason="stalled", retry_after_s=1.0),
CircuitOpenError(scope="llm", retry_after_s=1.0),
GovernanceBackendError("redis down", scope="llm"),
),
)
def test_scope_level_errors_carry_no_body(self, exc):
"""scope 级失败没有单一响应体可言,空串是如实表达而非噪音。"""
assert exc.body_text == ""
def test_body_text_does_not_leak_into_str(self):
"""字段是旁路数据: 加了它不得改变任何既有异常的 str() 输出。"""
assert str(RequestRejectedError("qwen_1 请求被拒: 400", body_text="whatever")) == (
"qwen_1 请求被拒: 400"
)
class TestResultInvalid: class TestResultInvalid:
def test_carries_diagnosis(self): def test_carries_diagnosis(self):
exc = ResultInvalidError( exc = ResultInvalidError(
@@ -134,9 +171,7 @@ class TestGovernanceBackendReason:
assert "governance_backend_down" in SCOPE_REASONS assert "governance_backend_down" in SCOPE_REASONS
def test_gateway_unavailable_accepts_the_new_reason(self): def test_gateway_unavailable_accepts_the_new_reason(self):
exc = AllSourcesExhausted( exc = AllSourcesExhausted(scope="LLM", reason="governance_backend_down", retry_after_s=0.0)
scope="LLM", reason="governance_backend_down", retry_after_s=0.0
)
assert exc.reason == "governance_backend_down" assert exc.reason == "governance_backend_down"
def test_retry_after_default_is_non_zero(self): def test_retry_after_default_is_non_zero(self):