From c29f4aef571abe91e249fe0720c3bbcca59b7386 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 10:59:07 +0500 Subject: [PATCH] =?UTF-8?q?fix(llm):=20cap=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BD=D0=BE=D0=B3=D0=BE=20Retry-After=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20=5FMAX=5FBACKOFF=5FS=3D30s=20(#1209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В 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 --- backend/app/services/llm/client.py | 20 +++++++++++++-- backend/tests/services/llm/test_client.py | 31 +++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/backend/app/services/llm/client.py b/backend/app/services/llm/client.py index d7abdb3a..71c960e5 100644 --- a/backend/app/services/llm/client.py +++ b/backend/app/services/llm/client.py @@ -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 diff --git a/backend/tests/services/llm/test_client.py b/backend/tests/services/llm/test_client.py index 3043fbf2..838165a2 100644 --- a/backend/tests/services/llm/test_client.py +++ b/backend/tests/services/llm/test_client.py @@ -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)