11 — Resilience & Error Handling (grag-client Policy)
Purpose. This document is the normative error-handling and resilience specification for
packages/grag-client (plan 2.5) and every Knoll-backend feature that calls the GRAG platform
(plan 7.4, 7.6). It defines: the master error taxonomy (what each HTTP status / SSE frame means
on GRAG and what the client and the German UI must do), the ⚠ missing-tenant hazard, the
retry/backoff and circuit-breaker policy (including the ~30 s deploy-churn window on every GRAG
merge), SSE stream resilience, model cold-start handling, rate-limit budgets, per-feature
degradation ladders, and the observability hooks that make failures diagnosable. An engineer
implementing grag-client should be able to code the error paths from this document alone.
Status / verified against: 2026-07-06, repo document-processing-pipelines @ db63a95
(live: https://app.grag.ai). Sibling docs: architecture in 01-architecture.md, request
recipes in 02-grag-api-cookbook.md, ID rules in 03-id-conventions.md, chat specifics in
09-chat-integration.md, ingest specifics in 10-document-pipeline.md, open platform gaps in
13-platform-gaps-issues.md, budget mechanics in 14-cost-model.md.
1. Scope & principles
- All GRAG traffic goes through grag-client. No feature code calls
fetchagainst$GRAG_URLdirectly. The client enforces headers, taxonomy mapping, retries, and the circuit breaker in one place. - Fail loudly on client-side contract violations (missing tenant, missing workspace, invalid ID charset) before a byte leaves the Knoll backend — see §3.
- Fail soft on platform degradation. GRAG itself is largely fail-open (retrieval gate
trims but never blocks, chat stages fail open except
generate, budget poller fails open). The Knoll UI mirrors that with explicit degraded states (§9), never with silent wrong data. - Knoll DB and the Knoll file store are the source of truth (plan 2.1/2.4, target architecture). Anything lost on the GRAG side (queued ingest, KG edges) must be replayable from Knoll-side state (§5.3, plan 3.6).
Every request example in this doc uses the standard header set. Base pattern
https://app.grag.ai/<traefik-prefix>/<internal-path>:
curl -sS "$GRAG_URL/workspaces/api/v1/kbs" \
-H "Authorization: Bearer $GRAG_API_KEY" \
-H "X-Tenant-ID: $GRAG_TENANT" \
-H "X-Request-ID: $(uuidgen)"
kg-service and entity-linking additionally require X-Workspace-ID on every non-public route
(400 without it).
2. Master error taxonomy
Error envelope everywhere: {"detail": "..."} (FastAPI style); every response carries an
X-Request-ID header (echoed if we sent one, else generated). Cross-tenant access always
returns 404, never 403 — a 404 therefore means "does not exist in our tenant".
⚠ Exception to the whole table: the BFF lane (/next/api/*) is unauthenticated today
(plan 1.8 — blocker). Until 1.8 lands, a 401/403 can never come from the BFF routes; after
1.8 the BFF rows behave like the rest of the table. The verification probe is plan 0.10a / 7.2.
| Signal | Where it occurs | Cause | grag-client behavior | User-visible message (German) |
|---|---|---|---|---|
401 (+ WWW-Authenticate: Bearer) | any service | Authorization header missing/malformed | No retry (config bug) — except one retry for GETs while the circuit breaker is in deploy-suspected state (transient 401 windows around deploys are documented platform behavior). Ops alert. | „KI-Funktionen vorübergehend nicht verfügbar. Der Administrator wurde benachrichtigt." |
| 403 (auth) | any service | Bearer key not in PIPELINE_API_KEY list / GATEWAY_CALLERS map | No retry. Ops alert (key rotated/revoked?). | wie 401 |
| 403 (admin gate) | POST /workspaces/api/v1/tenants, settings security keys, POST /mcp-gateway/api/v1/tokens | Route requires the operator admin key (WORKSPACES_ADMIN_API_KEY / MCP_GATEWAY_ADMIN_API_KEY — singular env names, plan 1.2); Knoll runtime key is expected to get 403 here | No retry. This is by design — route the action to the operator runbook (04-provisioning-runbook.md). | n/a (Provisioning-/Ops-Aktion) |
| 403 (keyvault) | ai-gateway LLM routes | BYOK stored key expired/disabled (ADR 0029 governance) | No retry. Mark AI features degraded; ops alert to rotate key via POST /ai-gateway/api/v1/keys/{id}/rotate. | „KI-Dienst derzeit nicht verfügbar (Schlüssel abgelaufen). Bitte Administration kontaktieren." |
| 400 (tenant) | any service | Invalid X-Tenant-ID charset (^[a-z0-9][a-z0-9-]{0,62}$) | Must never happen — grag-client validates IDs pre-flight (03-id-conventions.md). Treat as client bug: no retry, error log with payload shape (never content). | „Interner Fehler. Der Fehler wurde protokolliert." |
| 400 (workspace) | kg-service, entity-linking | Missing/invalid X-Workspace-ID | Client bug (pre-flight check). No retry. | wie oben |
| 400 (guardrail block) | ai-gateway LLM routes | Safety-guard blocking verdict in enforce mode (GATEWAY_GUARDRAIL_ENABLED — currently false in prod example) | No retry. Record verdict class in ai_runs.error. | „Die Anfrage wurde aus Sicherheitsgründen abgelehnt." |
| 400 (domain) | workspaces | e.g. self-supersede, project/workspace mismatch on KB create | No retry; fix the call. | „Interner Fehler…" |
| 402 | ai-gateway LLM routes | Keyvault monthly_budget_usd exceeded (hard governance, ADR 0029) | No retry. Set tenant-wide "AI paused" flag in Knoll DB; immediate ops + Partner alert (see §10; budget-80 % alert should fire first, plan 7.3). | „Das monatliche KI-Budget ist ausgeschöpft. KI-Funktionen sind pausiert — bitte Kanzlei-Administration kontaktieren." |
429 (+ Retry-After, X-RateLimit-*) | ai-gateway retrieval/pool groups; keyvault rate_limit_rpm | Sliding-window per-caller limit (120 rpm retrieval / 600 rpm pool, §8) or BYOK RPM cap | Retry: wait max(Retry-After, backoff) + jitter (§4, class R3). Invisible to the user when the retry succeeds. | (bei Erschöpfung der Versuche) „Zu viele Anfragen — bitte in einer Minute erneut versuchen." |
429 (Traefik, no guaranteed Retry-After) | any route | >100 r/s burst >200 from our IP | Same as above with fixed 2 s initial backoff. TODO-VERIFY: whether Traefik's rate-limit@file middleware sets Retry-After (probe live per 05-verification-runbook.md). | wie oben |
| 404 | any service | Resource absent or cross-tenant (404-not-403 semantics by design) | No retry. Reconcile against grag_refs: if we believe the resource exists, flag grag_refs.sync_status='missing' and surface; on GET /retrieval/collections/{kb}/info a 404 means "create the collection" (ingest lane). | „Das angeforderte Dokument wurde nicht gefunden." |
| 409 (create) | workspaces workspace/KB/conversation create, retrieval collection create | Duplicate (tenant, id) — with our deterministic client-chosen IDs this means already provisioned | Treat as success (plan 3.1). ⚠ There is NO Idempotency-Key support anywhere in workspaces — deterministic IDs + 409-as-success is the only idempotency mechanism. | keine (Erfolg) |
| 409 (job) | GET /docfold/api/v1/jobs/{id}/result | Job still running | Keep polling (this is a normal polling signal, not an error). | keine |
| 409 (lifecycle) | workspaces | Delete general workspace; reingest a superseded document | No retry; fix the call. | „Aktion nicht möglich." |
| 413 | POST …/retrieval/collections/{name}/points | Batch > GATEWAY_VOYAGER_MAX_BATCH_SIZE (256) | Split the batch and resend. Not a retry in the backoff sense. | keine |
| 422 | any service | Validation: unknown body fields (workspaces is extra="forbid"), schema inconsistency, unresolvable intent param | Never retry — this is a contract bug or bad input. Log request shape + X-Request-ID; fail the operation. One exception: transient 422 during a deploy window (contract just changed) — treat like the 401 deploy nuance and alert on contract drift (plan 1.9). | „Interner Fehler bei der Anfrage. Der Fehler wurde protokolliert." |
| 502 | docfold /convert* (engine failure); ai-gateway retrieval proxy (voyager 5xx); kg-service intent execute (entity-linking down); BFF /next/api/ingest/start (docfold/collection failure) | Downstream engine/dependency failure | Retry idempotent operations per §4 class R1 (docfold convert with the same document_id is safe — new job, same lineage keys). After budget exhausted: mark ingest failed, surface re-upload path (plan 4.2). | „Die Verarbeitung ist fehlgeschlagen. Bitte erneut versuchen." |
| 503 (dark flag) | settings (TENANT_SETTINGS_ENABLED), keyvault, graph-gateway, agent-control, entity-match (KG_EMBEDDINGS_ENABLED), rerank multi (RERANK_FUSION_DISABLE_MULTI) | Feature flag off — {"detail": "... disabled"} | No retry. This is feature detection, not an outage: set a per-feature "unavailable" flag (TTL ~5 min), hide/disable the feature in the UI (§9), log once. Verify expected flag state per plan 0.9/1.4. | „Diese Funktion ist derzeit nicht verfügbar." |
| 503 (fail-closed safety) | ai-gateway LLM routes with guardrail enforce mode ON | Safety-guard outage → fail-closed by design | One retry after 5 s, then fail the run. Distinguish from dark-flag 503 by the detail text and by which route it is. | „KI-Dienst vorübergehend nicht verfügbar." |
503 (admission control, + Retry-After: 30) | POST/DELETE …/retrieval/collections/{name}/points | Voyager WAL backlog past deny threshold (GATEWAY_ADMISSION_*; default Retry-After 30 s) | Retry honoring Retry-After (class R3); ingest jobs re-queue Knoll-side rather than block a request thread. checkpoint and wal/status routes are exempt — use GET …/wal/status to observe backlog. | keine (automatisch); bei Dauerzustand „Verarbeitung verzögert sich…" |
| Connection error (refused/reset/TLS/timeout) | anything | Deploy churn (--force-recreate on every merge, ~30 s), box outage | Feed the circuit breaker (§5). Retry per class after circuit closes. | „Die KI-Plattform wird gerade aktualisiert — einen Moment bitte." |
SSE event: error | chat stream (data:{message}), orchestrator events, agent relay | Chat: fatal only for the generate stage (all other stages emit trace … failed and continue). Orchestrator/agent: upstream failure. | Chat: end the turn as failed; the user message is already persisted (persist_user runs before LLM spend) — offer resend. Orchestrator: fall back to status polling. | Chat: „Die Antwort konnte nicht erstellt werden. Bitte erneut senden." |
SSE event: timeout | GET /next/api/orchestrator/events/{jobId} after 30 min | Hard stream ceiling (STREAM_TIMEOUT_MS) — the job may still be running | Switch to polling GET /next/api/pipeline/status/{jobId} (§6). | keine (Fortschritt läuft über Polling weiter) |
Decision rule for ambiguous 503: route + detail text. Dark-flag 503s are stable (same
answer every time → cache the unavailability); safety/admission 503s are transient (retry).
When in doubt, retry once; if the second answer is identical detail, treat as dark.
3. ⚠ The missing-tenant hazard (hard-fail before sending)
allow_default_tenant=true is the platform-wide default (pipeline-common settings) and is
not overridden in the prod env example: a request without X-Tenant-ID does not error —
it is silently served as tenant default. Consequences of one forgotten header:
- Writes (documents, entities, conversations, settings) land in tenant
default, invisible to tenantknoll— data loss from Knoll's perspective, orphaned PII from DSGVO's perspective (12-gdpr-compliance.md). - Reads return empty/foreign results with HTTP 200 — no error to catch downstream.
Policy (plan 2.5, 7.7 — non-negotiable):
- grag-client constructor requires a tenant ID; every request builder throws
GragClientError("tenant missing")if the resolved tenant is empty,default,system, orcanary(reserved IDs). X-Workspace-IDis mandatory for every kg-service/entity-linking call; the typed client methods for those services takeworkspaceIdas a required parameter — not an option.- ID charset validation (
^[a-z0-9][a-z0-9-]{0,62}$) runs client-side for tenant, workspace, KB, conversation and document IDs (03-id-conventions.md), so the 400 row in §2 is unreachable in practice. - A CI test asserts that no code path can construct a GRAG request without the two headers (unit test over the client's request middleware).
// packages/grag-client — the only place headers are assembled
function baseHeaders(scope: { tenant: string; workspace?: string }) {
assertDnsLabel(scope.tenant);
if (RESERVED_TENANTS.has(scope.tenant)) throw new GragClientError("reserved tenant");
return {
Authorization: `Bearer ${env.GRAG_API_KEY}`,
"X-Tenant-ID": scope.tenant, // hard-required, never defaulted
...(scope.workspace ? { "X-Workspace-ID": scope.workspace } : {}),
"X-Request-ID": crypto.randomUUID(),
};
}
4. Retry & backoff policy
4.1 Classes
| Class | Applies to | Retry on | Attempts | Backoff |
|---|---|---|---|---|
| R1 — idempotent reads & polls | All GETs; job polling; segments/text:batch; retrieval search/search/rerank (read-only POSTs) | connection error, 502, 503 (non-dark), 504 | 3 | exponential 1 s → 2 s → 4 s, full jitter (sleep = rand(0, base·2^n)) |
| R2 — 409-safe creates | Provisioning creates with deterministic client-chosen IDs (workspaces, KBs, conversations, collections — plan 3.1, 5.2); entities/upsert (idempotent by entity_id); KG edge writes via the 1.11 route (AGE MERGE is idempotent, plan 3.6) | same as R1; 409 = success, stop | 3 | as R1 |
| R3 — throttle/admission | 429 anywhere; 503 + Retry-After (WAL admission) | those signals only | 5 background / 2 interactive | max(Retry-After, expBackoff) + jitter(0–500 ms) |
| N — never retry | 400, 401*, 402, 403, 404, 413, 422, 409 (lifecycle), dark-flag 503, guardrail 400 | — | 0 | — (*401/404/422: single extra GET retry allowed only in deploy-suspected circuit state) |
| L — LLM completions | POST /ai-gateway/api/v1/chat/completions (and the structured-output helper, plan 1.14) | Never blind-retried at HTTP level after bytes were sent (non-idempotent spend). Model-level resilience uses the request's fallback: [≤5 models] + fallback_policy fields inside one call; semantic retries (invalid JSON, failed Zod parse) are owned by the ai_runs state machine with a bounded retry counter and cost stamping. | ai_runs-bounded (default 2 semantic retries/step) | state-machine controlled |
Timeout budget per attempt: 30 s default; 150 s for LLM completions and groundedness
(covers the BFF's own 120 s completion timeout and cold starts, §7); 60 s for
search/rerank first-call-after-deploy. Overall deadline for an interactive user-facing
operation: 180 s, then fail with the mapped German message.
4.2 What is explicitly NOT retried
- Validation failures (422/400): retrying cannot succeed and pollutes logs; they page the on-call as contract drift instead (plan 1.9).
- Cross-tenant-shaped 404s: retrying leaks time; reconcile
grag_refsinstead. - 402 budget: retrying spends nothing but signals wrong intent; hard stop + alert.
- Chat SSE turns: a dropped/failed stream is never auto-resent (the LLM may have partially
run; the user resends consciously). The user message is already persisted by the BFF's
persist_userstage, so no input is lost.
5. Circuit breaker & deploy-churn handling
5.1 The churn reality
GRAG deploys on every push to main with --force-recreate: all ~90 containers restart, no
blue/green, typical unavailability ~30 s, and transient 401/404/422 answers can occur while
containers come up behind Traefik (documented platform behavior; single-box SPOF). Knoll must
treat this as routine, not incident.
5.2 Breaker spec (one breaker for the whole GRAG origin)
- Open when: ≥3 connection-level failures (refused/reset/TLS/timeout) or 502/503 responses across distinct endpoints within 30 s.
- While open: all non-queued calls fail fast with the "wird gerade aktualisiert" state; ingest/KG writes divert to the replay queue (§5.3). Probe loop:
# public, unauthenticated liveness probe (no headers needed)
curl -fsS -m 3 "$GRAG_URL/workspaces/health"
- Half-open after 2 consecutive healthy probes (5 s interval): send ONE authenticated
canary read — this catches the deploy 401-window that
/healthcannot see:
curl -fsS -m 5 "$GRAG_URL/workspaces/api/v1/kbs" \
-H "Authorization: Bearer $GRAG_API_KEY" \
-H "X-Tenant-ID: knoll-dev"
- Close on 200; while half-open, only the canary flows.
- Alert if the breaker stays open > 5 min (that is no longer a deploy — see §10).
- State
deploy-suspected: set for 120 s after any open→close transition; within it, the §2 nuances apply (single extra retry for GET 401/404/422) and cold-start timeouts are used (§7).
5.3 Knoll-side job queue & replay (plan 7.4, risk table)
Ingest and KG writes must survive GRAG being down mid-operation:
- Uploads: the file lands in the Knoll file store first (plan 4.1/2.4). The
upload→convert→ingest sequence runs as a Knoll-side job (
dateien+grag_refsrows withsync_status ∈ pending | in_flight | done | failed | pending_replay). - On breaker-open or terminal connection failure, the job flips to
pending_replay; a worker replays it once the breaker closes. Replay is idempotent end-to-end: deterministicdocument_id/KB IDs (409 = already there), docfold re-convert is safe, voyager point IDs are chunk-derived, KG upserts/edges are MERGE-idempotent (plan 3.6 reconcile job covers the KG side on schedule as well). - In-flight ambiguity (connection died after send): re-poll by ID before re-submitting —
GET /workspaces/api/v1/kbs/{kb}/documents/GET /next/api/pipeline/status/{jobId}tell us whether the previous attempt took effect. - Chat and other interactive reads are not queued — they fail fast with the deploy state and let the user retry.
6. SSE resilience
Three streams matter (all via the BFF lane, blocked-for-prod by plan 1.8 like all BFF traffic):
6.1 Ingest progress — GET /next/api/orchestrator/events/{jobId}
- Contract: opening
event: subscribed, then verbatim orchestrator events (node_start/node_complete/node_fail/completed/failed/cancelled/…),: keepalivecomment every 20 s (KEEPALIVE_MS = 20_000), hard 30-min ceiling ending withevent: timeoutthen stream close (STREAM_TIMEOUT_MS = 30 min). Errors asevent: error. - Watchdog: if no bytes (data or keepalive) for 45 s, consider the stream stalled — close it and fall back to polling.
- On
timeout: the job may still be running. Fall back to polling; do NOT mark failed. - Fallback polling:
GET /next/api/pipeline/status/{jobId}every 5 s (status hash:{status, progress, error, results}); terminalfaileddrives the plan 4.2 UX (checklist item stays „Angefordert", visible error, re-upload path). - Reconnect semantics: the bridge is Redis pub/sub — missed events are NOT replayed. After any reconnect, poll the status endpoint once to resync before trusting the stream again.
6.2 Chat — POST /next/api/chat/stream
- POST-body SSE consumed via
fetch().body.getReader()(no EventSource, no auto-reconnect). - No keepalive frames exist on this stream (verified: no keepalive emission in
app/api/chat/stream/route.ts). Long silences are normal: the upstream completion is non-streaming (fake tokenization only starts after the full completion returns), and the BFF's completion timeout is 120 s, groundedness 30 s. - Watchdog: 150 s since last event → abort, show the chat error state. Do not auto-resend
(§4.2); the
persist_userstage has already saved the user turn. - Stage semantics: every
tracestage exceptgeneratefails open (status: failedon the trace event, stream continues). Map stage failures to the §9 degraded states instead of aborting. - A dropped stream mid-answer means the assistant message was likely not persisted (assistant persist happens at the end) — on reload, the turn shows the user message only; offer „Erneut senden".
- Remember plan 5.2: the conversation must exist (created via
POST /workspaces/api/v1/conversations) before the first stream call — the BFF does NOT create conversations, and persistence fails silently (fail-open) for unknown IDs.
6.3 Orchestrator/agent relay ceilings
- The 30-min ceiling means any pipeline expected to run longer (bulk ingest) must be monitored by polling, not by a single SSE subscription.
- Agent session relay (
/next/api/agents/sessions/{id}/stream, only if plan 5.6 spike happens): surfaceevent: errorframes; 503 = agent plane dark (profile not running).
6.4 Proxy buffering caveat
The documented Plesk-nginx snippet does not set proxy_buffering off for the SSE routes.
Symptom if buffered: all events arrive in one burst at stream end. TODO-VERIFY: live SSE probe
through https://app.grag.ai for both streams (plan 0.10b; test steps in
05-verification-runbook.md). If buffering is confirmed: ingest progress falls back to polling
permanently (acceptable), chat becomes an answer-at-once UX (not acceptable) → escalate as a
platform issue in 13-platform-gaps-issues.md.
7. Cold starts & preloads
Model-serving services lazy-load weights on first call:
| Service | Weights | First-call latency (cold) | Preload flag |
|---|---|---|---|
rerank-fusion (behind search/rerank) | ~1.2 GB (2 cross-encoders: ettin EN + bge-m3 multi) | 60–120 s | RERANK_FUSION_PRELOAD=true |
| groundedness | ~2.5–3.1 GB (3 models) | 60–120 s | GROUNDEDNESS_PRELOAD=true (+ GROUNDEDNESS_SPAN_PRELOAD — irrelevant for Knoll: spans are English-only and we skip them) |
| safety-guard (only if guardrail is enabled) | GLiNER2 300M | tens of seconds | SAFETY_GUARD_PRELOAD=true |
Policy:
- Plan 1.4 asks the operator to set the preload flags. TODO-VERIFY: live values of
RERANK_FUSION_PRELOAD/GROUNDEDNESS_PRELOADon app.grag.ai (flag dump viahost-diagnose.yml, plan 0.9). - Even with preloads, every merge recreates the containers — so after each breaker
open→close cycle, grag-client enters
deploy-suspected(§5.2) and uses the long first-call timeouts (60 s rerank, 150 s groundedness/LLM). - Optional warm-up (recommended for the Gutachten pipeline, plan 5.3): after the breaker closes, fire one background warm-up pair with generous timeouts:
# warm rerank-fusion via the compound route (tiny query, kb-methodik)
curl -sS -m 150 "$GRAG_URL/ai-gateway/api/v1/retrieval/collections/kb-methodik/search/rerank" \
-H "Authorization: Bearer $GRAG_API_KEY" \
-H "X-Tenant-ID: $GRAG_TENANT" \
-H "Content-Type: application/json" \
-d '{"query_text": "warmup", "top_k": 1}'
# warm groundedness
curl -sS -m 150 "$GRAG_URL/groundedness/api/v1/score" \
-H "Authorization: Bearer $GRAG_API_KEY" \
-H "X-Tenant-ID: $GRAG_TENANT" \
-H "Content-Type: application/json" \
-d '{"response_text": "warmup", "chunks": [{"chunk_id": "w1", "text": "warmup"}]}'
- UI: during a known warm-up window, the chat input shows „Die KI-Dienste starten gerade — die erste Antwort kann bis zu zwei Minuten dauern."
8. Rate limits & client-side throttling
| Layer | Limit | Scope / keying | Reject signal |
|---|---|---|---|
Traefik rate-limit@file (every router) | 100 req/s average, burst 200 | per source IP per router | 429 (TODO-VERIFY Retry-After presence) |
ai-gateway retrieval group | 120 rpm (GATEWAY_RATE_LIMIT_RETRIEVAL_RPM=120, window 60 s) | per caller name (knoll via GATEWAY_CALLERS, plan 0.5/1.1) | 429 + Retry-After + X-RateLimit-* |
ai-gateway pool group | 600 rpm (GATEWAY_RATE_LIMIT_POOL_RPM=600) | per caller | 429 + Retry-After |
| Keyvault stored key | rate_limit_rpm / monthly_budget_usd as configured in plan 1.6 | per tenant provider key | 429 / 402 |
| Anonymization vault reveal/deanonymize | 10/min, burst 5 | per source IP (Traefik priority router) | 429 |
| Orchestrator DLQ ops (BFF) | replay 10/min, purge 5/min | per actor (operator-facing only) | 429 |
⚠ Note the caller-attribution asymmetry (plan 0.5): BFF-lane traffic never presents Knoll's key (the BFF injects the platform key server-side), so the per-caller gateway buckets only govern grag-client's direct service calls until plan 1.8 adds BFF auth.
Client-side throttling policy (grag-client):
- Global concurrency cap: 8 concurrent GRAG requests (single-box backend; be a polite tenant), separate semaphore of 2 for ingest pipelines.
- Token bucket at 50 req/s sustained (half of the Traefik ceiling, leaving headroom for the platform's own UI traffic from other IPs — Traefik keys on our IP alone, but bursts of parallel Gutachten steps plus polling can approach it).
- Retrieval calls throttled to ≤100/min (headroom under the 120 rpm bucket); the Gutachten
pipeline step 2 (per-document
search/rerankfan-out, plan 5.3) processes documents sequentially with at most 2 in flight. - Polling intervals: job status 5 s, never below 2 s; back off polling ×2 while a 429 is hot.
- Bulk migration/backfill (e.g. initial
kb-methodikingest, plan 3.3): run at ≤30 rpm and outside business hours; if a real bulk load is planned, raise limits with the operator first (plan 1.15).
9. Degradation ladder per feature
Principle: every degraded mode is visible but non-blocking, and Knoll SQL is the fallback for anything whose source of truth is Knoll DB anyway (plan 3.7/5.4). UI copy in German.
| Feature | Dependency lost | Platform behavior | Knoll behavior / UI state |
|---|---|---|---|
| Chat (5.1a Akquise, 5.1b KIU, 5.2 „Frag die Akte") — retrieval stage | voyager/rerank down, collection missing | trace retrieve: failed, answer generated without sources (fail-open) | Show the answer with warning banner „Antwort ohne Quellenbezug — Dokumente konnten nicht durchsucht werden." Confidence pill hidden. Do NOT store the turn as grounded in ai_runs. |
| Chat — expand stage (kg full text) | kg-service down / KG_INGEST gap | Silent fallback to ~800-char previews | No user-visible change; sources drawer full-chunk view (GET /next/api/chat/chunk/{docId}/{ordinal}) may 404 → drawer shows „Volltext derzeit nicht verfügbar." |
| Chat — ground stage | groundedness down/cold | groundedness event missing or band unknown | Confidence pill grey: „Nicht bewertet". Never render green/amber/red for unknown. |
| Chat — generate stage | LLM/provider failure | SSE event: error (fatal) | Error bubble + „Erneut senden"; user turn preserved. |
Retrieval (direct search/rerank, Gutachten step 2) | rerank-fusion outage | Degrades to plain voyager hits: data.rerank = {degraded: true, reason, retrieval_confidence: "unknown"} — never fails | Accept hits but stamp ai_runs.groundedness_band='unknown' for the step; Gutachten pipeline continues, the affected section is flagged for Partner review („Beleglage unbestätigt"). |
| Retrieval — evidence gate | (never blocks by design) | Gate trims sources, keeps min_results floor | `retrieval_confidence ∈ weak |
| Groundedness scoring (5.3 per-step QA) | service down / 503 | n/a (direct call fails) | Step completes with band unknown; Partner review gate (mandatory anyway, plan 5.3) shows „Qualitätsbewertung nicht verfügbar" instead of a band. Never auto-release. |
| KG features — entity-match (5.4) | KG_EMBEDDINGS_ENABLED off / kg-service down → 503 | dark-flag 503 | Service-provider matching runs from Knoll SQL only (rating matrix + lever mapping — already the primary source); semantic discovery quietly absent, log once. |
| KG — intents / Knowledge-Graph Explorer (3.7/3.8) | GRAPH_GATEWAY_ENABLED off → 503 | dark | Explorer nav entry hidden (feature flag from §2 dark-503 detection). Phase-5 relationship queries are answered from Knoll SQL by design (plan 3.7) — no functional loss. |
| KG — deterministic writes (3.6) | kg-service down, edge write fails | 1.11 route fails loudly (by requirement) | Write recorded as pending_replay; idempotent reconcile job re-upserts from Knoll DB; alert on repeated failure + on kg_service_age_mirror_failures_total (§10). UI unaffected (KG is a projection). |
| Ingest/Checkliste (4.1–4.3) | any pipeline failure / GRAG down | job failed / queue Knoll-side | Checklist item stays „Angefordert" with error detail + „Erneut hochladen"; original is safe in the Knoll file store; auto-replay per §5.3. Classification suggestion (4.3) simply doesn't appear — manual status set remains available. |
| Budget/Verbrauch display (6.6) | ledger unreachable | n/a | KI-Einstellungen tab shows „Verbrauchsdaten derzeit nicht verfügbar" — never blocks settings edits. |
| Tenant settings read-through (6.6) | TENANT_SETTINGS_ENABLED off / 503 | dark | Show platform defaults, editing disabled with hint „Plattform-Einstellungen derzeit nicht verfügbar." |
| 402 budget exhausted | keyvault governance | hard reject on all LLM routes | Tenant-wide „KI pausiert" banner on all AI surfaces; deterministic features (Checkliste manuell, SQL-Matching, Kalkulation) fully usable. |
10. Observability hooks
10.1 Correlation
X-Request-ID: grag-client sets a fresh UUIDv4 on every request; the platform echoes it on the response (or generates one). Log it on both send and receive; store it inai_runs.refsandgrag_refs.last_errorcontext so a Knoll error can be joined to GRAG-side logs by the operator.X-Pipeline-Id: stamped once per Gutachten run / per Analyse batch (plan 7.3) on every ai-gateway call; this is the join key for per-Analyse cost:GET /ledger/api/v1/ledger/totals?pipeline_id=<run-id>(⚠ exact path — there is no per-pipeline grouping on/spend). Tenant-level budget display usesGET /ledger/api/v1/ledger/spend?group_by=service|provider(the only two groupings).
curl -sS "$GRAG_URL/ledger/api/v1/ledger/totals?pipeline_id=gutachten-a-042-run-7" \
-H "Authorization: Bearer $GRAG_API_KEY" \
-H "X-Tenant-ID: $GRAG_TENANT"
10.2 Structured logging (grag-client, one JSON line per attempt)
Fields: ts, level, request_id, tenant, workspace_id?, kb_id?, analyse_id?,
ai_run_id?, grag_service, method, path (templated, no IDs with PII), status,
error_class (taxonomy row from §2), attempt, retry_after_s?, latency_ms,
circuit_state, pipeline_id?. Never log request/response bodies (Mandanten content,
PII) — lengths and sha256 only, mirroring the platform's own discipline.
10.3 Client-side metrics (Knoll backend)
grag_client_requests_total{service,error_class,retried}grag_client_request_duration_seconds{service}(histogram)grag_client_circuit_state(0 closed / 1 half-open / 2 open) +grag_client_circuit_open_seconds_totalknoll_replay_queue_depth{kind=ingest|kg}andknoll_replay_failures_totalknoll_sse_fallback_total{stream=chat|orchestrator}
10.4 Alert conditions (plan 7.6)
| Alert | Condition | Severity |
|---|---|---|
| GRAG down (not a deploy) | circuit open > 5 min | page |
| Auth broken | any 401/403 (auth rows of §2) outside deploy-suspected | page (key rotation/config) |
| Budget hard stop | any 402 | page + Partner notification |
| Budget approaching | daily job: /ledger/api/v1/ledger/spend ≥ 80 % of monthly_budget_usd (plan 1.6/7.3) | warn |
| Contract drift | 422 rate > 0 on previously-green endpoints, or canary knoll-flow failure (plan 7.2) | warn → page if sustained |
| BFF exposure regression | unauthenticated probe of POST $GRAG_URL/next/api/chat/stream returns anything but 401 after plan 1.8 | page (security) |
| KG projection drift | operator-side Prometheus: kg_service_age_mirror_failures_total increases (plan 3.6) — Knoll-side proxy signal: knoll_replay_failures_total{kind=kg} > 0 or reconcile-job mismatch count > 0 | warn |
| Ingest stuck | knoll_replay_queue_depth{kind=ingest} > 0 for > 30 min, or job in pending_replay > 1 h | warn |
| Error budget | 5xx+connection error rate > 5 % of GRAG calls over 15 min | warn |
| SSE degraded | knoll_sse_fallback_total rising across restarts (possible proxy-buffering regression, §6.4) | info |
10.5 Canary tie-in
Plan 7.2's knoll-flow canary (create KB → ingest fixture → search → chat → purge) plus the
BFF-auth probe run on the GRAG side after every deploy; grag-client's circuit/deploy telemetry
is the Knoll-side mirror. When both fire simultaneously, it is a platform regression, not a
Knoll bug — file it against 13-platform-gaps-issues.md.
Appendix A — Taxonomy quick map for implementers
connection error ─────────▶ circuit breaker (§5) ─▶ queue/replay or fail-fast
401/403 (auth) ───────────▶ no retry, ops alert (deploy-window nuance §2)
403 (admin gate) ─────────▶ operator runbook (04-provisioning-runbook.md)
400 tenant/workspace ─────▶ unreachable if §3 enforced (client bug)
402 ──────────────────────▶ hard stop, "KI pausiert", page
404 ──────────────────────▶ our-tenant-miss; reconcile grag_refs
409 create ───────────────▶ SUCCESS (already provisioned) [plan 3.1]
409 docfold result ───────▶ keep polling
413 ──────────────────────▶ split batch (≤256 points)
422 ──────────────────────▶ contract bug, never retry [plan 1.9]
429 ──────────────────────▶ Retry-After + jitter (class R3)
502 ──────────────────────▶ retry idempotent (R1), else surface + re-upload path
503 dark flag ────────────▶ feature-unavailable cache, hide feature
503 fail-closed safety ───▶ one retry, then fail
503 + Retry-After (WAL) ──▶ honor Retry-After / re-queue ingest
SSE error ────────────────▶ chat: fail turn; jobs: fall back to polling
SSE timeout (30 min) ─────▶ switch to polling, job not failed
Appendix B — Open verification items in this document
- TODO-VERIFY: does Traefik's
rate-limit@file429 carry aRetry-Afterheader? (Live probe; grag-client currently assumes it may be absent and uses a 2 s floor.) - TODO-VERIFY: SSE pass-through vs. buffering through Plesk nginx for
/next/api/chat/streamand/next/api/orchestrator/events/{id}(plan 0.10b; procedure in05-verification-runbook.md). - TODO-VERIFY: live values of
RERANK_FUSION_PRELOAD,GROUNDEDNESS_PRELOAD(and the plan 0.9 flag dump generally) on app.grag.ai — cold-start timeouts stay in force until confirmed.