RandomZ / app /llm /llm_throttle.py
StormShadow308's picture
feat: async pipeline, job queue, generation hardening, and docs
732b14f
Raw
History Blame Contribute Delete
6.33 kB
from __future__ import annotations
import asyncio
import random
import threading
import time
from typing import Awaitable, Callable, TypeVar
import structlog
from tenacity import (
AsyncRetrying,
RetryCallState,
retry_if_exception_type,
)
from app.config import settings
try:
# openai v1: RateLimitError lives in the top-level module
from openai import RateLimitError
except Exception: # pragma: no cover
RateLimitError = Exception # type: ignore[assignment,misc]
T = TypeVar("T")
_semaphore: asyncio.Semaphore | None = None
_sync_semaphore: threading.Semaphore | None = None
# Configure structlog once on import. This is intentionally minimal: JSON to
# stdout with enough fields for the latency optimisation work.
structlog.configure(
processors=[
structlog.processors.TimeStamper(fmt="iso_8601", utc=True),
structlog.processors.add_log_level,
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.JSONRenderer(),
],
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
_log = structlog.get_logger(__name__)
def _get_semaphore() -> asyncio.Semaphore:
global _semaphore
if _semaphore is None:
_semaphore = asyncio.Semaphore(int(settings.max_concurrent_llm_calls))
return _semaphore
def _get_sync_semaphore() -> threading.Semaphore:
global _sync_semaphore
if _sync_semaphore is None:
_sync_semaphore = threading.Semaphore(int(settings.max_concurrent_llm_calls))
return _sync_semaphore
def _exp_backoff_with_jitter(retry_state: RetryCallState) -> float:
"""Exponential backoff with +/-20% jitter."""
# attempt_number starts at 1 for the first retry.
attempt = retry_state.attempt_number
base = 1.0 * (2 ** (attempt - 1))
base = min(base, 60.0)
jitter = base * random.uniform(-0.2, 0.2)
return max(0.0, base + jitter)
def make_cache_hit_slot() -> list[bool | None]:
"""Mutable slot callers fill after ``log_openai_cache_usage`` inside ``call``."""
return [None]
def _resolved_cache_hit(
cache_hit: bool | None,
cache_hit_out: list[bool | None] | None,
) -> bool | None:
if cache_hit_out is not None and len(cache_hit_out) > 0:
return cache_hit_out[0]
return cache_hit
async def throttled_llm_call(
*,
phase: str,
section_id: str | None,
call: Callable[[], Awaitable[T]],
cache_hit: bool | None = None,
cache_hit_out: list[bool | None] | None = None,
) -> T:
"""Wrap an LLM call with:
- global concurrency semaphore
- exponential backoff with jitter on OpenAI RateLimitError
- structured JSON logs via structlog
"""
sem = _get_semaphore()
start = time.perf_counter()
last_exc: BaseException | None = None
async with sem:
retrying = AsyncRetrying(
retry=retry_if_exception_type(RateLimitError),
wait=_exp_backoff_with_jitter,
reraise=False,
)
async for attempt in retrying:
try:
result = await call()
duration_ms = int((time.perf_counter() - start) * 1000)
hit = _resolved_cache_hit(cache_hit, cache_hit_out)
_log.info(
event="llm_call",
duration_ms=duration_ms,
phase=phase,
section_id=section_id,
cache_hit=hit,
attempt=attempt.retry_state.attempt_number,
)
return result
except BaseException as exc: # noqa: BLE001
last_exc = exc
duration_ms = int((time.perf_counter() - start) * 1000)
hit = _resolved_cache_hit(cache_hit, cache_hit_out)
_log.warning(
event="llm_call_rate_limited",
duration_ms=duration_ms,
phase=phase,
section_id=section_id,
cache_hit=hit,
attempt=attempt.retry_state.attempt_number,
exc_type=type(exc).__name__,
)
# If we got here, reraise=False and we exhausted retries.
duration_ms = int((time.perf_counter() - start) * 1000)
hit = _resolved_cache_hit(cache_hit, cache_hit_out)
_log.error(
event="llm_call_exhausted_retries",
duration_ms=duration_ms,
phase=phase,
section_id=section_id,
cache_hit=hit,
)
if last_exc is None:
raise RuntimeError("LLM call exhausted retries without exception")
raise last_exc
def throttled_sync_llm_call(
*,
phase: str,
section_id: str | None,
call: Callable[[], T],
) -> T:
"""Sync OpenAI calls with global concurrency cap and rate-limit retries."""
from tenacity import Retrying, retry_if_exception_type
sem = _get_sync_semaphore()
start = time.perf_counter()
with sem:
retrying = Retrying(
retry=retry_if_exception_type(RateLimitError),
wait=_exp_backoff_with_jitter,
reraise=True,
)
for attempt in retrying:
with attempt:
try:
result = call()
except RateLimitError:
duration_ms = int((time.perf_counter() - start) * 1000)
_log.warning(
event="llm_call_rate_limited",
duration_ms=duration_ms,
phase=phase,
section_id=section_id,
cache_hit=None,
attempt=attempt.retry_state.attempt_number,
exc_type="RateLimitError",
)
raise
duration_ms = int((time.perf_counter() - start) * 1000)
_log.info(
event="llm_call",
duration_ms=duration_ms,
phase=phase,
section_id=section_id,
cache_hit=None,
attempt=attempt.retry_state.attempt_number,
)
return result
raise RuntimeError("unreachable")