fix(llm): cap серверного Retry-After через _MAX_BACKOFF_S=30s (#1209) #1261

Merged
bot-backend merged 1 commit from fix/llm-client-retry-after-cap into main 2026-06-13 06:07:47 +00:00
2 changed files with 49 additions and 2 deletions

View file

@ -92,6 +92,15 @@ class LLMResult:
_COST_PER_1K_PROMPT_USD = 0.00015
_COST_PER_1K_COMPLETION_USD = 0.00060
# #1209: общий потолок backoff'а между retry-попытками (секунды). Применяется
# к ОБЕИМ веткам — exponential backoff И серверному Retry-After. Provider'ы
# шлют Retry-After до 86400с при quota-exhaustion (видели у OpenAI / CDN-503);
# без капа time.sleep блокирует anyio-threadpool на часы → пул из ~40 потоков
# исчерпывается → стопор приложения (sync Depends(get_db) тоже сидят в этом
# пуле). 30с — компромисс между «дать провайдеру отдышаться» и «не убивать
# приложение». Реальный raw_wait логируется для наблюдаемости.
_MAX_BACKOFF_S: float = 30.0
def _estimate_cost_usd(prompt_tokens: int, completion_tokens: int) -> float:
return (
@ -212,13 +221,20 @@ def _call_with_retries(
except LLMRateLimitedError as e:
last_reason = "rate_limited"
if attempt < max_retries:
wait = e.retry_after if e.retry_after is not None else min(2**attempt, 30)
# #1209: cap И серверное Retry-After (раньше min(...,30) применялся
# только к exp.backoff). _MAX_BACKOFF_S — единый потолок для обеих
# веток, защищает anyio-threadpool от blocking на часы.
raw_wait = float(
e.retry_after if e.retry_after is not None else 2**attempt
)
wait = min(raw_wait, _MAX_BACKOFF_S)
logger.warning(
"llm: HTTP %s (attempt %d/%d), backing off %.1fs",
"llm: HTTP %s (attempt %d/%d), backing off %.1fs (raw=%.1fs)",
e.status_code,
attempt + 1,
max_retries + 1,
wait,
raw_wait,
)
time.sleep(wait)
continue

View file

@ -241,6 +241,37 @@ def test_rate_limited_retries_then_fallback(
assert prov.calls == 3
def test_rate_limited_retry_after_capped(
_enabled: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""#1209: серверный Retry-After (86400с при quota-exhaustion) должен капаться
_MAX_BACKOFF_S иначе time.sleep блокирует anyio-threadpool на часы.
"""
monkeypatch.setattr(settings, "llm_max_retries", 1)
sleeps: list[float] = []
monkeypatch.setattr(llm_client.time, "sleep", lambda s: sleeps.append(s))
class _BigRetryAfterProvider(LLMProvider):
@property
def is_external(self) -> bool:
return True
@property
def model(self) -> str:
return "gpt-4o-mini"
def complete(self, messages: Any, *, tools: Any = None, max_output_tokens: int) -> Any:
raise LLMRateLimitedError("429", status_code=429, retry_after=86400.0)
prov = _BigRetryAfterProvider()
res = complete(system_prompt="sys", payload=SafePayload(text="q"), provider=prov)
assert res.ok is False
assert res.reason == "rate_limited"
# ровно один backoff (1 ретрай); значение должно быть капано _MAX_BACKOFF_S (30с).
assert len(sleeps) == 1
assert sleeps[0] == llm_client._MAX_BACKOFF_S
def test_rate_limited_then_success(_enabled: None, monkeypatch: pytest.MonkeyPatch) -> None:
"""429 один раз, затем успех → ok (ретрай сработал)."""
monkeypatch.setattr(settings, "llm_max_retries", 2)