[Fable 5][P2] Неограниченный time.sleep по серверному Retry-After блокирует threadpool на часы #1209

Closed
opened 2026-06-13 04:49:43 +00:00 by bot-backend · 0 comments
Collaborator

Из армейского ревью Claude Fable 5 (2026-06-11)

Confirmed by 2/2 адверсариальных верификатора, severity P2 (performance), confidence 0.8.

Файл

backend/app/services/llm/client.py:215

Evidence (цитата кода)

wait = e.retry_after if e.retry_after is not None else min(2**attempt, 30)
                logger.warning(
                    "llm: HTTP %s (attempt %d/%d), backing off %.1fs",
                    ...
                )
                time.sleep(wait)

Why bug

Cap min(...,30) применяется только к вычисленному backoff; значение из заголовка Retry-After не ограничено. _parse_retry_after принимает любое число секунд ("86400".isdigit() → True). При 429/5xx с большим Retry-After (OpenAI шлёт его при quota-exhaustion; 503 от CDN/прокси тоже) sync-вызов спит до 2×Retry-After в потоке anyio-threadpool'а (чат мостится через run_in_threadpool, дефолтный пул ~40 потоков). HTTP-запрос пользователя висит, несколько одновременных чат-ходов в момент rate-limit-события исчерпывают пул и стопорят все run_in_threadpool приложения.

Suggested fix

Ограничить и серверное значение: wait = min(e.retry_after if e.retry_after is not None else 2**attempt, 30). Опционально — общий дедлайн на complete() (бюджет времени на все ретраи).

Verify notes

  • [real/P2] Трасса: 429/5xx с Retry-After:86400 → _parse_retry_after: isdigit→float без капа → client.py:215 берёт e.retry_after как есть (min(...,30) только в else-ветке) → time.sleep(86400) дважды (llm_max_retries=2) = до 48ч на один complete() при бюджете llm_timeout_s=30. Вызов из chat.py:132 через run_in_threadpool: поток не отменяем, держит токен anyio-пула (40, общий с sync Depends(get_db)) и DB-сессию → пул исчерпывается, стопор приложения. Смягчение: llm_enabled default False → P2.
  • [real/P2] Защит нет: _parse_retry_after без капа («86400».isdigit()→86400.0), min(...,30) только в else-ветке, run_in_threadpool без таймаута, db-сессия удерживается весь sleep (пинит и пул SQLAlchemy). До 2 sleep на complete (llm_max_retries=2) = 2×Retry-After на запрос; 5xx тоже идут через LLMRateLimitedError. Конвенция проекта подтверждает баг: objective.py капит тот же заголовок min(...,300). Единственное смягчение — llm_enabled=False по умолчанию → P2, не P1.

Provenance

  • Finders: chat-llm
  • Confidence: 0.8
  • Originally batched at: main@d796027
## Из армейского ревью Claude Fable 5 (2026-06-11) Confirmed by 2/2 адверсариальных верификатора, severity **P2** (performance), confidence 0.8. ## Файл [`backend/app/services/llm/client.py:215`](backend/app/services/llm/client.py:215) ## Evidence (цитата кода) ``` wait = e.retry_after if e.retry_after is not None else min(2**attempt, 30) logger.warning( "llm: HTTP %s (attempt %d/%d), backing off %.1fs", ... ) time.sleep(wait) ``` ## Why bug Cap min(...,30) применяется только к вычисленному backoff; значение из заголовка Retry-After не ограничено. _parse_retry_after принимает любое число секунд ("86400".isdigit() → True). При 429/5xx с большим Retry-After (OpenAI шлёт его при quota-exhaustion; 503 от CDN/прокси тоже) sync-вызов спит до 2×Retry-After в потоке anyio-threadpool'а (чат мостится через run_in_threadpool, дефолтный пул ~40 потоков). HTTP-запрос пользователя висит, несколько одновременных чат-ходов в момент rate-limit-события исчерпывают пул и стопорят все run_in_threadpool приложения. ## Suggested fix Ограничить и серверное значение: wait = min(e.retry_after if e.retry_after is not None else 2**attempt, 30). Опционально — общий дедлайн на complete() (бюджет времени на все ретраи). ## Verify notes - [real/P2] Трасса: 429/5xx с Retry-After:86400 → _parse_retry_after: isdigit→float без капа → client.py:215 берёт e.retry_after как есть (min(...,30) только в else-ветке) → time.sleep(86400) дважды (llm_max_retries=2) = до 48ч на один complete() при бюджете llm_timeout_s=30. Вызов из chat.py:132 через run_in_threadpool: поток не отменяем, держит токен anyio-пула (40, общий с sync Depends(get_db)) и DB-сессию → пул исчерпывается, стопор приложения. Смягчение: llm_enabled default False → P2. - [real/P2] Защит нет: _parse_retry_after без капа («86400».isdigit()→86400.0), min(...,30) только в else-ветке, run_in_threadpool без таймаута, db-сессия удерживается весь sleep (пинит и пул SQLAlchemy). До 2 sleep на complete (llm_max_retries=2) = 2×Retry-After на запрос; 5xx тоже идут через LLMRateLimitedError. Конвенция проекта подтверждает баг: objective.py капит тот же заголовок min(...,300). Единственное смягчение — llm_enabled=False по умолчанию → P2, не P1. ## Provenance - Finders: `chat-llm` - Confidence: 0.8 - Originally batched at: main@d796027
bot-backend added the
Fable 5 ревью
label 2026-06-13 04:49:43 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#1209
No description provided.