Sentinel — Failure Handling¶
Phase 15 documentation deliverable. The frozen decision table lives in project record §06; this document walks the shipped machinery — classification, circuit breaker, emergency limiter, HTTP semantics, and the measured failure-path latency — with code references.
Sentinel's failure philosophy, stated once: a Redis failure never causes a request to wait
beyond the configured timeout, and every failure resolves to a row in the decision table. No
bare except:, no silent fallbacks, no unbounded fail-open.
1 · The decision table¶
Every evaluation ends in exactly one DecisionReason (8 members, sentinel/models.py:34):
| Redis outcome | Fail-open (Resumint) | Fail-closed (PDFTalk) |
|---|---|---|
| Success | Lua result (ALLOWED / RATE_LIMITED) |
Lua result (ALLOWED / RATE_LIMITED) |
| Timeout (20 ms) | Emergency local limiter | Deny → FAIL_CLOSED |
| Connection error | Emergency local limiter | Deny → FAIL_CLOSED |
NOSCRIPT (re-load exhausted) |
Emergency local limiter | Deny → FAIL_CLOSED |
| Circuit breaker OPEN | Emergency local limiter | Deny → CIRCUIT_OPEN |
The fail_mode comes from the endpoint's Policy — it is a product decision per integration,
not a global setting (ADR-006): PDFTalk's expensive OCR fails closed to protect compute;
Resumint's UX-sensitive tailoring fails open but never unlimited.
2 · Classification (sentinel/errors.py)¶
classify_redis_error(exc) -> DecisionReason maps the failure to a bounded reason
(sentinel/errors.py:14):
| Exception | Reason |
|---|---|
redis.exceptions.TimeoutError |
REDIS_TIMEOUT |
redis.exceptions.ConnectionError |
REDIS_CONNECTION_ERROR |
ScriptMissingError (NOSCRIPT re-load exhausted) |
REDIS_NOSCRIPT_RETRY |
Any other RedisError |
REDIS_CONNECTION_ERROR |
RateLimiter.evaluate catches only RedisError (sentinel/limiter.py:147). Programming
errors — KeyError, RuntimeError, the unloaded-scripts guard — are never caught and
propagate to the host app: a programming bug must not be mistaken for a Redis outage.
NOSCRIPT recovery (sentinel/redis.py:68): ScriptLoader runs EVALSHA; on
NoScriptError it re-SCRIPT LOADs the source and re-runs once. If the script is still
missing (e.g. script cache flushed mid-flight again), it raises ScriptMissingError — which is
itself a classified failure reason, not a crash.
3 · Circuit breaker (sentinel/circuit_breaker.py)¶
A per-process CLOSED / OPEN / HALF_OPEN state machine guarding the Redis boundary:
- CLOSED — calls reach Redis; each failure counts; 5 consecutive failures (default
FAILURE_THRESHOLD,sentinel/circuit_breaker.py:16) trip OPEN. Any genuine Redis success resets the count. - OPEN — every evaluation short-circuits with
CIRCUIT_OPENbefore any Redis call (sentinel/limiter.py:143), for the quarantine window (30 s default,OPEN_TIMEOUT_SECONDS). - HALF_OPEN — entered lazily after the quarantine; each arriving call is a probe. Success → CLOSED (count reset); failure → OPEN with a fresh quarantine.
Deliberate properties:
- Per-process, not distributed (ADR-007): N API instances have N independent breakers. Instance-targeted abuse is capped by the emergency limiter regardless of which instance a request lands on.
- Local monotonic clock (
sentinel/circuit_breaker.py:29): the documented exception to the Redis-clock invariant — the breaker exists precisely because Redis may be unreachable. - Only genuine successes reset the failure count — a fail-open allowance via the emergency limiter is not a Redis success and leaves the breaker alone.
4 · Emergency limiter (sentinel/emergency.py)¶
The fail-open cap: a per-process, in-memory token bucket keyed by endpoint_id only.
- Capacity = refill rate =
fallback_rate_per_process_micro— a burst of one second's worth of the fallback rate, then sustained at the fallback rate. - No-write-on-deny (
sentinel/emergency.py:70): bucket state is persisted only on ALLOW, mirroring the Lua's "denied requests never write" contract. This is the Phase 14 double-refill fix — before it, denied calls banked partial refills and sustained fail-open allowance reached ~2.3× the configured rate. - Denied →
EMERGENCY_LOCAL_LIMITwithremaining_microandretry_after_seconds. - Local monotonic clock (
sentinel/emergency.py:42) — same documented exception as the breaker. - Fail-open requests allowed by the emergency limiter carry the cause as the reason
(
CIRCUIT_OPEN,REDIS_TIMEOUT, ...) withallowed=True(sentinel/limiter.py:173) — the decision table stays lossless: metrics count them, the deny log does not.
Limits of the emergency limiter (see known-limitations.md): per-process allowance multiplies with instance count, and buckets are per-endpoint — no cross-tenant fairness during an outage.
5 · HTTP semantics (sentinel/http.py)¶
_denied_status (sentinel/http.py:47) maps denied reasons to status codes:
| HTTP status | Reasons | Body / headers |
|---|---|---|
| 429 Too Many Requests | RATE_LIMITED, EMERGENCY_LOCAL_LIMIT |
detail="rate limit exceeded"; Retry-After when the decision carries one |
| 503 Service Unavailable | FAIL_CLOSED, CIRCUIT_OPEN, REDIS_TIMEOUT, REDIS_CONNECTION_ERROR, REDIS_NOSCRIPT_RETRY |
detail="rate limiter unavailable" |
Retry-After is emitted only when the decision provides it: the token-bucket strategy computes
ceil((1_000_000 - tokens_after) / rate) (min 1, sentinel/limiter.py:74); the sliding-window
strategy deliberately returns none — the Lua result does not expose enough timing information
for a precise value (sentinel/limiter.py:106).
Auth failures (missing/invalid/expired token) return 401 with WWW-Authenticate: Bearer
before the resolver or limiter run (sentinel/http.py:110) — they never produce a
DecisionReason, never touch Redis, and emit no log or metric.
6 · Measured failure-path latency¶
Phase 14 baseline, disclosed as-is (single-machine Docker-Compose loopback; benchmark-results.md):
| Cell | Journey | p50 | p99 |
|---|---|---|---|
| B7 | Breaker-OPEN short-circuit (pre-tripped) | ≈ 7 µs (~96k ops/s) | — |
| B8/B9 | Real dead-port failure, fail-open / fail-closed (breaker starts CLOSED, trips mid-run) | ≈ 22–27 ms | ≈ 22–29 ms |
The failure path is dominated by the 20 ms dead-port socket budget (sentinel/redis.py:10)
— the limiter itself is not the failure-path cost; the breaker short-circuit is essentially
free. Under CPU saturation the failure cells stay in the same band (p99 ≈ 29–37 ms), nowhere
near the benchmark client's 5 s budget (a benchmark-only override; production keeps 20 ms).
7 · Not idempotent (ADR-011)¶
A Redis call can time out locally while the script still commits server-side. A client retry after a timeout may consume additional quota beyond what the client believes it used. This is a documented property, not a bug: idempotency keys are out of scope for V1 (see known-limitations.md).
8 · Testing the failure paths¶
- Unit:
tests/test_errors.py(classification),tests/test_circuit_breaker.py(state machine with injected clock),tests/test_emergency.py(sustained-rate regression at 1/2/5 tokens/s). - Integration:
tests/test_limiter.pyfail-open/fail-closed journeys throughRateLimiter;tests/test_http.pyHTTP mapping (429 vs 503, Retry-After). - Concurrency + real failure injection (
slowsuite): a dedicatedSentinelRedispointed at a dead port (Linux surfacesREDIS_CONNECTION_ERROR, Windows/WSL2REDIS_TIMEOUT— both accepted) trips the breaker under 50-coroutine load while the emergency limiter caps fail-open traffic; fail-closed counterparts deny everything. - Benchmark: B7–B9 in
benchmarks/benchmark.pyrecord the failure journey end to end.