From da77b123ec1e41addcf459d7063502fe94472d8c Mon Sep 17 00:00:00 2001 From: iomgaa Date: Thu, 10 Sep 2026 04:20:56 -0400 Subject: [PATCH] fix: ignore non-finite Retry-After hints A gateway that answers 429 with Retry-After: inf (or 1e999, which float() happily rounds to inf) used to reach backoff_delay as retry_after_s=inf. max(delay, retry_after) then picked it, and since the library deliberately does not clamp the hint with backoff_max_s, the attempt slept forever. - _parse_retry_after now rejects non-finite values via math.isinf and returns None, so the call falls back to plain exponential backoff - it emits exactly one warning carrying the source name and the verdict word retry_after_not_finite, never the raw header: under a 429 storm an echoed header drowns the real signal and does not help localisation - source_name becomes a required keyword-only argument; the function is private, so no default is given and a missed call site fails loudly instead of silently dropping the source identity from the warning - nan keeps flowing through the existing seconds > 0 semantics; no new branch, no reordering of the parse --- src/polygateway/transports/openai_compat.py | 23 ++++++- tests/unit/test_openai_compat.py | 76 ++++++++++++++++++++- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/src/polygateway/transports/openai_compat.py b/src/polygateway/transports/openai_compat.py index 55f79ea..e09572c 100644 --- a/src/polygateway/transports/openai_compat.py +++ b/src/polygateway/transports/openai_compat.py @@ -9,6 +9,7 @@ SSE 纯函数移植 VT `adapters/llm.py:51-124`;错误翻译移植 CHS from __future__ import annotations import json +import math import re import time from dataclasses import replace @@ -108,14 +109,30 @@ async def _iter_sse_deltas( # —— 错误翻译(CHS invokers.py 同款)—— -def _parse_retry_after(raw: str | None) -> float | None: - """解析 Retry-After 头;仅支持秒数形态,HTTP-date 返回 None(CHS 同款)。""" +def _parse_retry_after(raw: str | None, *, source_name: str) -> float | None: + """解析 Retry-After 头;仅支持秒数形态,HTTP-date 返回 None(CHS 同款)。 + + **非有限值必须当作"无提示"**(issue F1): `"inf"` / `"1e999"` 能被 `float()` + 成功解析,又能通过 `seconds > 0`,于是一路变成 `retry_after_s=inf`——而 + `backoff_delay` 的 `max(delay, retry_after)` 取大之后就是一次**永不醒来**的 + 退避 sleep(库刻意不拿 `backoff_max_s` 去夹它,见设计 §6.2)。 + + `source_name` 是**必填** keyword-only 参数: 本函数是私有的,不给默认值, + 漏传即 `TypeError`,免得将来新增调用点静默丢掉源标识(告警定位不到是哪个源)。 + `nan` 不新增分支,沿用既有 `seconds > 0` 恒假的值语义。 + """ if raw is None: return None try: seconds = float(raw.strip()) except ValueError: return None + if math.isinf(seconds): + # 只写源名与判据词: 429 风暴下回显原始头会把日志淹掉,也无助于定位 + logger.warning( + "{} 的 Retry-After 非有限值,按无提示处理(retry_after_not_finite)", source_name + ) + return None return seconds if seconds > 0 else None @@ -137,7 +154,7 @@ def _translate_429( ) return TransientError( compose_message(f"{source.name} 限速: 429", summary), - retry_after_s=_parse_retry_after(headers.get("retry-after")), + retry_after_s=_parse_retry_after(headers.get("retry-after"), source_name=source.name), **ctx, ) diff --git a/tests/unit/test_openai_compat.py b/tests/unit/test_openai_compat.py index b65f0c4..47102aa 100644 --- a/tests/unit/test_openai_compat.py +++ b/tests/unit/test_openai_compat.py @@ -14,6 +14,7 @@ from polygateway.errors import ( SourceDeadError, TransientError, ) +from polygateway.middleware.retry import backoff_delay from polygateway.middleware.telemetry import TelemetryEmitter from polygateway.pricing import ModelPrice, PricingTable from polygateway.providers import ProviderProfile, ThinkingWire, register_provider @@ -21,9 +22,18 @@ from polygateway.transports._http_errors import summarize_body from polygateway.transports.openai_compat import ( OpenAICompatTransport, _iter_sse_deltas, + _parse_retry_after, _sse_data_payload, + _translate_429, +) +from polygateway.types import ( + ChatRequest, + Effort, + LLMResponse, + RetryPolicy, + SourceConfig, + ThinkingObservation, ) -from polygateway.types import ChatRequest, Effort, LLMResponse, SourceConfig, ThinkingObservation def _source(**overrides): @@ -1144,3 +1154,67 @@ async def test_custom_profile_raw_roots_cannot_override_managed_intent(key): assert sent == [] finally: await transport.aclose() + + +class TestRetryAfterNonFinite: + """F1: `Retry-After` 非有限值必须当作"无提示"(计划 §3.6 / §5 批次 G)。 + + `float("inf")` 能被 `float()` 成功解析,又能通过既有的 `seconds > 0`—— + 它会一路变成 `retry_after_s=inf`,而 `backoff_delay` 的 `max(delay, retry_after)` + 取大之后就是一次**永不醒来**的退避 sleep(库不夹 `backoff_max_s`)。 + """ + + def _translate(self, raw, *, name="qwen_1"): + return _translate_429(_source(name=name), "{}", {"retry-after": raw}, {"body_text": "{}"}) + + @pytest.mark.parametrize("raw", ["inf", "-inf", "1e999", "Infinity"]) + def test_non_finite_becomes_no_hint_with_exactly_one_warning(self, raw): + messages: list[str] = [] + sink_id = logger.add(messages.append, level="WARNING") + try: + exc = self._translate(raw) + finally: + logger.remove(sink_id) + assert exc.retry_after_s is None + hits = [m for m in messages if "retry_after_not_finite" in m] + assert len(hits) == 1 + assert "qwen_1" in hits[0] # 告警要能定位到源 + assert raw not in hits[0] # 但不回显原始头字符串(不拼接、不截断) + + @pytest.mark.parametrize( + "raw", ["nan", "", " ", "-1", "0", "Wed, 21 Oct 2026 07:28:00 GMT", "soon"] + ) + def test_other_unusable_values_stay_silent(self, raw): + """429 风暴下逐次告警会淹掉真信号: 只有非有限值这一类新增告警。 + + `nan` 仍走既有的 `seconds > 0` 恒假值语义,本版**不为它新增分支**。 + """ + messages: list[str] = [] + sink_id = logger.add(messages.append, level="WARNING") + try: + exc = self._translate(raw) + finally: + logger.remove(sink_id) + assert exc.retry_after_s is None + assert [m for m in messages if "retry_after_not_finite" in m] == [] + + def test_finite_positive_still_reaches_backoff(self): + """有限正数一字不改地保留,并照常参与 `max(delay, retry_after)` 取大。""" + exc = self._translate("2.5") + assert exc.retry_after_s == 2.5 + policy = RetryPolicy(max_attempts=3, backoff_base_s=0.001, backoff_max_s=0.01) + assert backoff_delay(policy, 1, exc, lambda: 0.5) == 2.5 + # 而非有限值被吃掉之后,退避退回纯指数,不会变成永不醒来的 sleep + assert backoff_delay(policy, 1, self._translate("inf"), lambda: 0.5) < 1.0 + + def test_source_name_is_a_required_keyword(self): + """私有函数的必填 kw: 漏传即 `TypeError`,不给默认值掩盖调用点漏改。""" + with pytest.raises(TypeError): + _parse_retry_after("2.5") + assert _parse_retry_after("2.5", source_name="qwen_1") == 2.5 + + def test_insufficient_quota_is_still_source_dead(self): + """分类判据不受本次改动影响(告警只加在 429 限速那一支)。""" + body = json.dumps({"error": {"type": "insufficient_quota"}}) + exc = _translate_429(_source(), body, {"retry-after": "inf"}, {"body_text": body}) + assert isinstance(exc, SourceDeadError)