From e3022470226ef2b1ce29934fc4b03d7108e4646a Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sun, 16 Aug 2026 06:01:38 -0400 Subject: [PATCH] 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. --- src/polygateway/errors.py | 34 ++++++++++++++++++++++++++++++-- tests/unit/test_errors.py | 41 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/polygateway/errors.py b/src/polygateway/errors.py index 1473c91..6fdf8c5 100644 --- a/src/polygateway/errors.py +++ b/src/polygateway/errors.py @@ -35,7 +35,26 @@ SOURCE_REASONS = frozenset( 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__( self, @@ -44,11 +63,13 @@ class PolyGatewayError(Exception): source_name: str | None = None, status_code: int | None = None, operation: str | None = None, + body_text: str = "", ) -> None: super().__init__(message) self.source_name = source_name self.status_code = status_code self.operation = operation + self.body_text = body_text class TransientError(PolyGatewayError): @@ -64,7 +85,16 @@ class SourceDeadError(PolyGatewayError): class RequestRejectedError(PolyGatewayError): - """请求被拒(400/坏输入): 不重试不换源,直接上抛。""" + """请求被拒(400/坏输入): 不重试不换源,直接上抛。 + + **经中转部署时请注意**(issue #10 下游实测): 第三方 API 中转服务自身抖动 + 时也会回 400,从状态码上与供应商说"你的输入有问题"无法区分。下游曾观测到 + 同一份字节(sha256 一致)重发 15 次全部成功,且失败那次 `prompt_tokens=0`、 + 耗时远低于任何成功调用——请求在推理开始前就被挡了。本库仍按确定性失败处理 + (对直连供应商而言重试只会白烧配额),批处理场景的下游宜自备兜底分类; + `body_text` 即为此提供判据: 中转抖动的响应体与供应商的 `invalid_request_error` + 形态不同。 + """ class ResultInvalidError(PolyGatewayError): diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index a440f81..ca5646c 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -34,6 +34,43 @@ class TestBaseShape: 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: def test_carries_diagnosis(self): exc = ResultInvalidError( @@ -134,9 +171,7 @@ class TestGovernanceBackendReason: assert "governance_backend_down" in SCOPE_REASONS def test_gateway_unavailable_accepts_the_new_reason(self): - exc = AllSourcesExhausted( - scope="LLM", reason="governance_backend_down", retry_after_s=0.0 - ) + exc = AllSourcesExhausted(scope="LLM", reason="governance_backend_down", retry_after_s=0.0) assert exc.reason == "governance_backend_down" def test_retry_after_default_is_non_zero(self):