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
This commit is contained in:
2026-09-10 04:20:56 -04:00
parent 9474c76ab0
commit da77b123ec
2 changed files with 95 additions and 4 deletions
+75 -1
View File
@@ -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)