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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user