fix(llm): cap серверного Retry-After через _MAX_BACKOFF_S=30s (#1209)
Some checks are pending
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / changes (push) Successful in 7s
Some checks are pending
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / changes (push) Successful in 7s
В complete-loop'е min(...,30) применялся ТОЛЬКО к exponential backoff'у
(else-ветка). Серверный Retry-After уходил в time.sleep как есть.
_parse_retry_after принимает любое число секунд ("86400".isdigit() → True),
а provider'ы шлют Retry-After до 86400с при quota-exhaustion (OpenAI,
CDN-503). Эффект: time.sleep блокирует поток anyio-threadpool на часы
(до 48ч при llm_max_retries=2). Async-консьюмер (chat.py) мостится через
run_in_threadpool — поток держит токен пула (~40 потоков, общий с sync
Depends(get_db)) и DB-сессию → пул исчерпывается → стопор приложения.
Patch: вынес кап на module-level _MAX_BACKOFF_S=30s, применяю к ОБЕИМ
веткам (Retry-After И exp.backoff). raw_wait логируется отдельно для
наблюдаемости (видно когда провайдер просил больше).
Новый юнит-тест test_rate_limited_retry_after_capped: provider шлёт
retry_after=86400 → time.sleep вызывается с 30 (не 86400). 14/14
client тестов + 55/55 LLM-suite зелёные. ruff clean.
Closes #1209
This commit is contained in:
parent
4c2f19ace0
commit
c29f4aef57
2 changed files with 49 additions and 2 deletions
|
|
@ -92,6 +92,15 @@ class LLMResult:
|
||||||
_COST_PER_1K_PROMPT_USD = 0.00015
|
_COST_PER_1K_PROMPT_USD = 0.00015
|
||||||
_COST_PER_1K_COMPLETION_USD = 0.00060
|
_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:
|
def _estimate_cost_usd(prompt_tokens: int, completion_tokens: int) -> float:
|
||||||
return (
|
return (
|
||||||
|
|
@ -212,13 +221,20 @@ def _call_with_retries(
|
||||||
except LLMRateLimitedError as e:
|
except LLMRateLimitedError as e:
|
||||||
last_reason = "rate_limited"
|
last_reason = "rate_limited"
|
||||||
if attempt < max_retries:
|
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(
|
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,
|
e.status_code,
|
||||||
attempt + 1,
|
attempt + 1,
|
||||||
max_retries + 1,
|
max_retries + 1,
|
||||||
wait,
|
wait,
|
||||||
|
raw_wait,
|
||||||
)
|
)
|
||||||
time.sleep(wait)
|
time.sleep(wait)
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,37 @@ def test_rate_limited_retries_then_fallback(
|
||||||
assert prov.calls == 3
|
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:
|
def test_rate_limited_then_success(_enabled: None, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""429 один раз, затем успех → ok (ретрай сработал)."""
|
"""429 один раз, затем успех → ok (ретрай сработал)."""
|
||||||
monkeypatch.setattr(settings, "llm_max_retries", 2)
|
monkeypatch.setattr(settings, "llm_max_retries", 2)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue