Skip to content

Commit 0185bc6

Browse files
authored
Merge pull request #47 from LAA-Software-Engineering/cursor/llm-resilience-1cc6
feat: LLM timeouts, retries, auto-fallback, and circuit breaker (#19)
2 parents e28d640 + 87ff814 commit 0185bc6

12 files changed

Lines changed: 875 additions & 67 deletions

File tree

‎.env.example‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,16 @@ INGEST_RETRY_AFTER_SECONDS=5
112112
# Max in-flight LLM provider calls (process-wide; independent of API concurrency).
113113
# 0 = unlimited. Noop provider skips the wait.
114114
# LLM_MAX_CONCURRENCY=4
115+
116+
# LLM resilience (timeout, retries, token ceiling, circuit breaker). Unused when
117+
# LLM_PROVIDER=disabled (noop). On timeout/error/open breaker, explain/ask fall
118+
# back to deterministic templates and set llm.fell_back=true; the request succeeds.
119+
# LLM_TIMEOUT=30
120+
# LLM_MAX_RETRIES=2 # extra attempts after the first (3 total)
121+
# LLM_MAX_TOKENS=600 # completion cap (OpenAI max_tokens / Ollama num_predict)
122+
# LLM_MAX_INPUT_TOKENS=0 # 0 = derive from LLM_MAX_TOKENS (chars/4 estimate)
123+
# LLM_BREAKER_THRESHOLD=5 # consecutive failures before the breaker opens
124+
# LLM_BREAKER_COOLDOWN_SECONDS=60
115125
# Max NDJSON lines accepted by POST /v1/ingestions/lines (over cap → 400).
116126
INGEST_PUSH_MAX_LINES=5000
117127
# Tail-mode poll cadence and consecutive-error auto-pause.

‎README.md‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,12 @@ All settings are read from `.env`, environment variables, or CLI flags. Priority
696696
| `INGEST_QUEUE_MAX` | `100` | Pending worker-job ceiling; over this, ingest returns `429 INGEST_QUEUE_FULL` |
697697
| `INGEST_RETRY_AFTER_SECONDS` | `5` | `Retry-After` value on `429 INGEST_QUEUE_FULL` |
698698
| `LLM_MAX_CONCURRENCY` | `4` | Max in-flight LLM provider calls process-wide. `0` = unlimited. Noop does not wait |
699+
| `LLM_TIMEOUT` | `30` | Per-attempt HTTP timeout in seconds for OpenAI/Ollama calls |
700+
| `LLM_MAX_RETRIES` | `2` | Extra attempts after the first (3 total) with jittered exponential backoff |
701+
| `LLM_MAX_TOKENS` | `600` | Completion cap (`max_tokens` / Ollama `num_predict`) |
702+
| `LLM_MAX_INPUT_TOKENS` | `0` | Estimated input-token budget (`chars/4`). `0` derives from `LLM_MAX_TOKENS`. Over budget: trim evidence (respecting `MAX_EVIDENCE_ITEMS`) or fall back |
703+
| `LLM_BREAKER_THRESHOLD` | `5` | Consecutive LLM failures before the process-local breaker opens |
704+
| `LLM_BREAKER_COOLDOWN_SECONDS` | `60` | Seconds the breaker stays open before a half-open probe |
699705
| `WEBHOOK_SECRET` | _(empty)_ | Fallback HMAC secret for ingest completion callbacks when auth is off or the API key has no per-key `whsec_` |
700706
| `WEBHOOK_MAX_RETRIES` | `5` | Extra webhook POST attempts after the first (6 POSTs by default) on 5xx / 429 / connect errors |
701707
| `WEBHOOK_TIMEOUT` | `10` | Per-attempt HTTP timeout in seconds for completion callbacks |
@@ -709,6 +715,8 @@ raglogs is fully useful without any LLM. The `--no-llm` flag (or `LLM_PROVIDER=d
709715

710716
When an LLM is configured, it receives only a small curated evidence packet — not raw logs. The prompt enforces fixed output structure, prohibits fabrication, and requires explicit uncertainty statements when evidence is insufficient. In-flight provider calls are capped by `LLM_MAX_CONCURRENCY` (CLI and API share the process semaphore; the noop provider does not block).
711717

718+
Every provider call has a timeout (`LLM_TIMEOUT`) and bounded jittered retries (`LLM_MAX_RETRIES`). On timeout, HTTP error, exhausted retries, or an over-budget evidence payload, explain/ask **fall back to the same deterministic templates** and set `llm.fell_back=true` — the request still succeeds. After `LLM_BREAKER_THRESHOLD` consecutive failures a process-local circuit breaker opens for `LLM_BREAKER_COOLDOWN_SECONDS`; while open, raglogs skips the provider entirely and serves templates. `GET /health` exposes `llm_breaker: {state, consecutive_failures, cooldown_remaining_seconds}` (`closed` / `open` / `half_open`). An open breaker marks `status` as `degraded` but still returns HTTP 200 so probes do not fail. Fallback never invents: it only renders the curated evidence packet.
719+
712720
### OpenAI
713721

714722
```env
@@ -970,7 +978,7 @@ If auth is disabled and the process binds a non-loopback address (`0.0.0.0`, `::
970978

971979
| Method | Endpoint | Description |
972980
|---|---|---|
973-
| `GET` | `/health` | Service and DB health check (unversioned). Includes `tail_jobs: {running, paused}`. |
981+
| `GET` | `/health` | Service and DB health check (unversioned). Includes `tail_jobs: {running, paused}` and `llm_breaker` (`closed` / `open` / `half_open`). Open breaker → `status: degraded`, still HTTP 200. |
974982
| `POST` | `/v1/ingestions` | Enqueue a batch ingest job (`adapter`: `file`, `cloudwatch`, `datadog`, `loki`, or `k8s`). Set `"mode": "tail"` for pull adapters to start a long-lived tail job. |
975983
| `POST` | `/v1/ingestions/lines` | Push NDJSON of raw or pre-parsed log lines (sync persist) |
976984
| `POST` | `/v1/ingestions/{id}:pause` | Pause a tail job |
@@ -1014,7 +1022,7 @@ curl -X POST http://localhost:8000/v1/ingestions/$ID:stop
10141022
- **Queue depth.** When pending worker jobs ≥ `INGEST_QUEUE_MAX` (default 100), `POST /v1/ingestions` and `POST /v1/ingestions/lines` return **429** with `Retry-After` (`INGEST_RETRY_AFTER_SECONDS`, default 5) and body `{"error_code":"INGEST_QUEUE_FULL","message":"..."}`. Tail ticks skip the same ceiling.
10151023
- **API token bucket.** `POST /v1/ingestions*` (writes) and `/v1/query*` (plus unversioned aliases) are limited per API key (`request.state.auth_principal.key_id`, or a single `anonymous` bucket when `AUTH_ENABLED=false`). Exceeding the bucket returns **429** with `Retry-After` (`RATELIMIT_RETRY_AFTER_SECONDS`, default 1) and body `{"error_code":"RATE_LIMITED","message":"..."}`. Defaults (`RATELIMIT_INGEST_RPS` / `RATELIMIT_QUERY_RPS` / `RATELIMIT_BURST` = 100) are high enough for local demo and tests; `0` rps means unlimited for that category. `/health`, `/docs`, static UI, and `/config` are not limited. Buckets are in-memory per process.
10161024

1017-
LLM calls are separately capped by `LLM_MAX_CONCURRENCY` (default 4) so a burst of `explain` cannot fan out unbounded provider requests.
1025+
LLM calls are separately capped by `LLM_MAX_CONCURRENCY` (default 4) so a burst of `explain` cannot fan out unbounded provider requests. Timeouts, retries, automatic template fallback (`llm.fell_back`), and the process-local circuit breaker are described under [LLM integration](#llm-integration).
10181026

10191027
**Idempotency-Key.** `POST /v1/ingestions` (batch enqueue and tail create; also the deprecated `/ingestions` alias) honors an `Idempotency-Key` header (max 256 characters). A repeat **in the same isolation scope** within `INGEST_IDEMPOTENCY_TTL_SECONDS` (default 86400) returns the original **202** job — the same `worker_job_id` for batch, the same `ingestion_job_id` for tail — instead of starting a new one. Reusing another scope's key returns **409** `IDEMPOTENCY_SCOPE_CONFLICT`. Empty keys return **400**. GET routes ignore the header. `POST /v1/ingestions/lines` does not use the header; duplicate push/tail lines are handled by content dedup instead.
10201028

‎src/api/routes/health.py‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,19 @@
1212
_adapter_health_cache: dict[str, tuple[float, str]] = {}
1313

1414

15+
class LlmBreakerHealth(BaseModel):
16+
state: str # closed | open | half_open
17+
consecutive_failures: int
18+
cooldown_remaining_seconds: float
19+
20+
1521
class HealthResponse(BaseModel):
1622
status: str # ok | degraded
1723
db: str # connected | disconnected
1824
worker_queue_depth: Optional[int] # pending worker jobs; None if DB unreachable
1925
adapters: dict[str, str] # adapter name -> "ok" | "unavailable: <reason>"
2026
tail_jobs: Optional[dict[str, int]] = None # {running, paused}; None if DB unreachable
27+
llm_breaker: Optional[LlmBreakerHealth] = None
2128

2229

2330
def _adapter_health() -> dict[str, str]:
@@ -52,12 +59,25 @@ def _adapter_health() -> dict[str, str]:
5259
return statuses
5360

5461

62+
def _llm_breaker_health() -> LlmBreakerHealth:
63+
from src.core.llm.resilience import breaker_health
64+
65+
snap = breaker_health()
66+
return LlmBreakerHealth(
67+
state=str(snap["state"]),
68+
consecutive_failures=int(snap["consecutive_failures"]),
69+
cooldown_remaining_seconds=float(snap["cooldown_remaining_seconds"]),
70+
)
71+
72+
5573
@router.get("/health", response_model=HealthResponse)
56-
def health_check():
74+
def health_check() -> HealthResponse:
5775
from src.db.session import check_connection, get_db
5876

5977
db_ok = check_connection()
6078
adapters = _adapter_health()
79+
llm_breaker = _llm_breaker_health()
80+
breaker_open = llm_breaker.state == "open"
6181

6282
if not db_ok:
6383
return HealthResponse(
@@ -66,6 +86,7 @@ def health_check():
6686
worker_queue_depth=None,
6787
adapters=adapters,
6888
tail_jobs=None,
89+
llm_breaker=llm_breaker,
6990
)
7091

7192
try:
@@ -83,9 +104,10 @@ def health_check():
83104
tail_jobs = None
84105

85106
return HealthResponse(
86-
status="ok" if db_ok else "degraded",
107+
status="degraded" if breaker_open else "ok",
87108
db="connected",
88109
worker_queue_depth=depth,
89110
adapters=adapters,
90111
tail_jobs=tail_jobs,
112+
llm_breaker=llm_breaker,
91113
)

‎src/config/settings.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@ class Settings(BaseSettings):
7272
# API concurrency. 0 = unlimited. Noop provider skips the wait.
7373
llm_max_concurrency: int = 4
7474

75+
# LLM resilience (G10). Timeouts/retries wrap every provider HTTP call.
76+
# On failure the pipeline falls back to deterministic templates.
77+
# 0 input-token budget means "derive from LLM_MAX_TOKENS".
78+
llm_timeout: float = 30.0
79+
llm_max_retries: int = 2
80+
llm_max_tokens: int = 600
81+
llm_max_input_tokens: int = 0
82+
llm_breaker_threshold: int = 5
83+
llm_breaker_cooldown_seconds: float = 60.0
84+
7585
# HMAC-signed ingest completion webhooks (G5). WEBHOOK_SECRET is the
7686
# fallback when AUTH_ENABLED=false or the API key has no per-key secret.
7787
webhook_secret: str = ""

‎src/core/explain/summarizer.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from datetime import datetime
44
from typing import Optional
55

6+
import structlog
67
from sqlalchemy.orm import Session
78

89
from src.config import get_settings
@@ -14,6 +15,8 @@
1415
from src.db.models import DEFAULT_LOG_SCOPE, IngestionJob
1516
from src.db.scope_filter import filter_ingestion_jobs_by_scope
1617

18+
log = structlog.get_logger()
19+
1720

1821
@dataclass
1922
class ExplainResult:
@@ -122,8 +125,9 @@ def explain_window(
122125
summary_text = llm_text
123126
mode = "llm"
124127
except Exception:
125-
# Degrade gracefully
126-
pass
128+
# Timeout, retries exhausted, open breaker, or budget: keep mode
129+
# "rules" so llm.fell_back is true when an LLM was requested.
130+
log.warning("llm_explain_failed", exc_info=True)
127131

128132
if not summary_text:
129133
summary_text = render_text_summary(packet, confidence)

‎src/core/llm/provider.py‎

Lines changed: 76 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
from typing import Any, Protocol, runtime_checkable
66

77
import httpx
8-
from tenacity import retry, stop_after_attempt, wait_exponential
8+
9+
from src.core.llm.resilience import invoke_llm, prepare_llm_packet
910

1011

1112
@runtime_checkable
@@ -39,6 +40,18 @@ def generate_summary(self, evidence_packet: dict) -> str:
3940
If evidence is insufficient, say so clearly. Keep the entire output under 300 words. No markdown formatting."""
4041

4142

43+
def _llm_timeout() -> float:
44+
from src.config import get_settings
45+
46+
return float(get_settings().llm_timeout)
47+
48+
49+
def _llm_max_tokens() -> int:
50+
from src.config import get_settings
51+
52+
return int(get_settings().llm_max_tokens)
53+
54+
4255
class OpenAILLMProvider:
4356
def __init__(
4457
self,
@@ -50,12 +63,9 @@ def __init__(
5063
self.model = model
5164
self.base_url = base_url.rstrip("/")
5265

53-
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
54-
def generate_summary(self, evidence_packet: dict) -> str:
55-
payload = json.dumps(evidence_packet, default=str, indent=2)
56-
user_message = f"Analyze this incident evidence and produce a summary:\n\n{payload}"
57-
58-
with httpx.Client(timeout=60) as client:
66+
def complete(self, system_prompt: str, user_message: str) -> str:
67+
"""Single OpenAI chat-completions attempt (timeout/max_tokens from settings)."""
68+
with httpx.Client(timeout=_llm_timeout()) as client:
5969
response = client.post(
6070
f"{self.base_url}/chat/completions",
6171
headers={
@@ -64,10 +74,10 @@ def generate_summary(self, evidence_packet: dict) -> str:
6474
},
6575
json={
6676
"model": self.model,
67-
"max_tokens": 600,
77+
"max_tokens": _llm_max_tokens(),
6878
"temperature": 0,
6979
"messages": [
70-
{"role": "system", "content": SYSTEM_PROMPT},
80+
{"role": "system", "content": system_prompt},
7181
{"role": "user", "content": user_message},
7282
],
7383
},
@@ -76,6 +86,11 @@ def generate_summary(self, evidence_packet: dict) -> str:
7686
data = response.json()
7787
return data["choices"][0]["message"]["content"].strip()
7888

89+
def generate_summary(self, evidence_packet: dict) -> str:
90+
payload = json.dumps(evidence_packet, default=str, indent=2)
91+
user_message = f"Analyze this incident evidence and produce a summary:\n\n{payload}"
92+
return self.complete(SYSTEM_PROMPT, user_message)
93+
7994

8095
class OllamaLLMProvider:
8196
def __init__(
@@ -86,25 +101,55 @@ def __init__(
86101
self.model = model
87102
self.base_url = base_url.rstrip("/")
88103

89-
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=5))
90-
def generate_summary(self, evidence_packet: dict) -> str:
91-
payload = json.dumps(evidence_packet, default=str, indent=2)
92-
prompt = f"{SYSTEM_PROMPT}\n\nIncident evidence:\n{payload}\n\nSummary:"
93-
94-
with httpx.Client(timeout=120) as client:
104+
def complete(self, system_prompt: str, user_message: str) -> str:
105+
"""Single Ollama /api/generate attempt (timeout/num_predict from settings)."""
106+
prompt = f"{system_prompt}\n\n{user_message}"
107+
with httpx.Client(timeout=_llm_timeout()) as client:
95108
response = client.post(
96109
f"{self.base_url}/api/generate",
97110
json={
98111
"model": self.model,
99112
"prompt": prompt,
100113
"stream": False,
101-
"options": {"temperature": 0},
114+
"options": {
115+
"temperature": 0,
116+
"num_predict": _llm_max_tokens(),
117+
},
102118
},
103119
)
104120
response.raise_for_status()
105121
data = response.json()
106122
return data.get("response", "").strip()
107123

124+
def generate_summary(self, evidence_packet: dict) -> str:
125+
payload = json.dumps(evidence_packet, default=str, indent=2)
126+
user_message = f"Incident evidence:\n{payload}\n\nSummary:"
127+
return self.complete(SYSTEM_PROMPT, user_message)
128+
129+
130+
class ResilientLLMProvider:
131+
"""G10 breaker + token budget + retries around an inner provider.
132+
133+
Lives *inside* ``CappedLLMProvider`` so in-flight slots cover the whole
134+
retry sequence, but the breaker check still skips HTTP when open.
135+
"""
136+
137+
def __init__(self, inner: LLMProvider) -> None:
138+
self.inner = inner
139+
140+
def generate_summary(self, evidence_packet: dict) -> str:
141+
from src.config import get_settings
142+
143+
prepared = prepare_llm_packet(evidence_packet, get_settings())
144+
return invoke_llm(lambda: self.inner.generate_summary(prepared))
145+
146+
def complete(self, system_prompt: str, user_message: str) -> str:
147+
inner = self.inner
148+
complete = getattr(inner, "complete", None)
149+
if complete is None:
150+
return ""
151+
return invoke_llm(lambda: complete(system_prompt, user_message))
152+
108153

109154
_llm_sem_lock = threading.Lock()
110155
_llm_semaphore: threading.Semaphore | None = None
@@ -153,21 +198,24 @@ class CappedLLMProvider:
153198
154199
Noop inner providers skip the wait so deterministic mode never blocks,
155200
but still go through this entrypoint (CLI and API share the semaphore).
201+
202+
Stack (outer → inner): CappedLLMProvider → ResilientLLMProvider → OpenAI/Ollama.
203+
Noop skips ResilientLLMProvider entirely.
156204
"""
157205

158206
def __init__(self, inner: LLMProvider) -> None:
159207
self.inner = inner
160208

161209
def generate_summary(self, evidence_packet: dict) -> str:
162-
skip = isinstance(self.inner, NoopLLMProvider)
210+
skip = isinstance(unwrap_llm_provider(self), NoopLLMProvider)
163211
with llm_concurrency_slot(skip=skip):
164212
return self.inner.generate_summary(evidence_packet)
165213

166214

167215
def unwrap_llm_provider(provider: LLMProvider) -> LLMProvider:
168-
"""Return the inner provider if ``provider`` is concurrency-capped."""
216+
"""Return the inner provider if ``provider`` is concurrency-capped or resilient."""
169217
inner: LLMProvider = provider
170-
while isinstance(inner, CappedLLMProvider):
218+
while isinstance(inner, (CappedLLMProvider, ResilientLLMProvider)):
171219
inner = inner.inner
172220
return inner
173221

@@ -190,5 +238,12 @@ def _build_inner_llm_provider(settings: Any) -> LLMProvider:
190238

191239

192240
def build_llm_provider(settings: Any) -> LLMProvider:
193-
"""Factory: build the configured LLM provider (concurrency-capped)."""
194-
return CappedLLMProvider(_build_inner_llm_provider(settings))
241+
"""Factory: build the configured LLM provider (resilient + concurrency-capped).
242+
243+
Order: CappedLLMProvider (G9) wraps ResilientLLMProvider (G10) wraps the
244+
HTTP provider. Noop skips the resilience wrapper so disabled mode is unchanged.
245+
"""
246+
inner = _build_inner_llm_provider(settings)
247+
if not isinstance(inner, NoopLLMProvider):
248+
inner = ResilientLLMProvider(inner)
249+
return CappedLLMProvider(inner)

0 commit comments

Comments
 (0)