Sentinel — Installation Guide¶
A step-by-step guide from zero to a working rate-limited FastAPI endpoint. The README is the overview; this document is the hands-on walkthrough.
Contents¶
- Prerequisites
- Install the library
- Set up Redis
- Create the configuration file
- Wire Sentinel into FastAPI
- Generate a JWT and test it
- Watch it fail (fail-open vs fail-closed)
- Observability
- Troubleshooting
- Where to go next
1. Prerequisites¶
| Requirement | Version | Notes |
|---|---|---|
| Python | ≥ 3.11 | |
| Redis | 7.x | a dedicated instance with noeviction + bounded maxmemory — Sentinel refuses to start otherwise |
| FastAPI app | any | Sentinel is a library that lives inside your app; no service to deploy |
The library itself pulls in redis, fastapi, pydantic, PyJWT, and prometheus-client.
2. Install the library¶
The import name is sentinel (the distribution name differs from the import name — this is
intentional).
For development on Sentinel itself (running the test suite, benchmarks):
3. Set up Redis¶
Sentinel needs a Redis instance it can trust: noeviction means keys are never silently
dropped, and a bounded maxmemory forces you to size the instance deliberately. If you cloned
the repo, it ships a docker-compose.yml with exactly the required config:
The equivalent raw config (if you run Redis yourself, or if you only pip-installed the library and don't have the repo):
Verify against a running instance:
docker compose exec redis redis-cli CONFIG GET maxmemory-policy
# -> maxmemory-policy
# -> noeviction
Sentinel checks this at startup (assert_noeviction()) and raises if the policy is not
noeviction — a misconfigured Redis cannot silently weaken your quotas.
4. Create the configuration file¶
Sentinel loads a strict JSON config (SentinelConfig is frozen; unknown keys are rejected,
and policy dict keys must match the policy's endpoint_id). The repo has a working example at
sentinel.example.json — copy it and edit (pip-only consumers can
grab the same file from the GitHub repo, or write it by hand following the annotated sections
below):
{
"app": {
"redis_url": "redis://localhost:6379/0",
"jwt_secret": "dev-only-secret-change-me-0123456789abcdef",
"jwt_algorithm_allowlist": ["HS256"]
},
"policies": {
"pdftalk.ingest": {
"endpoint_id": "pdftalk.ingest",
"algorithm": "sliding_window",
"fail_mode": "fail_closed",
"fallback_rate_per_process_micro": 5000,
"policy_version": 1,
"limit": 1000,
"window_size_micro": 60000000
},
"resumint.tailor": {
"endpoint_id": "resumint.tailor",
"algorithm": "token_bucket",
"fail_mode": "fail_open",
"fallback_rate_per_process_micro": 2000,
"policy_version": 1,
"capacity_micro": 10000000,
"refill_rate_micro_per_sec": 10000
}
}
}
app section¶
| Field | Meaning |
|---|---|
redis_url |
redis:// URL of the dedicated instance |
jwt_secret |
Shared HMAC secret, min 32 chars |
jwt_algorithm_allowlist |
Non-empty subset of HS256/HS384/HS512 (JWKS/asymmetric deferred to V2) |
policies section¶
Each policy is keyed by (and must declare) an explicit endpoint_id — the id is never
derived from the URL path. Common fields:
| Field | Meaning |
|---|---|
endpoint_id |
Explicit configured id, pattern ^[a-z0-9._-]+$ |
algorithm |
token_bucket or sliding_window |
fail_mode |
fail_closed (503 when the store fails) or fail_open (capped emergency limiter) |
fallback_rate_per_process_micro |
Fail-open allowance per process (µtokens/s); burst = 1 s of this rate |
policy_version |
Bump deliberately when a policy changes (it is part of the Redis key) |
Token-bucket fields: capacity_micro (burst size) and refill_rate_micro_per_sec (sustained
rate). Sliding-window fields: limit and window_size_micro (default 60 s). Mixing the two
families is rejected at load, and configuration whose arithmetic would exceed Lua's integer
exactness (2^53) is rejected too.
All values are microtokens (1 token = 1,000,000 µtokens). A capacity of
10000000is 10 tokens; a refill rate of10000is 0.01 tokens/s — tune these to your scale. Integer math only; no floats anywhere in the state.
5. Wire Sentinel into FastAPI¶
This is the entire integration — it mirrors the real wiring in
tests/test_http_integration.py. Create app.py:
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Depends, FastAPI, Request
from sentinel.config import load_config
from sentinel.http import SentinelGuard
from sentinel.redis import ScriptLoader, SentinelRedis
config = load_config(Path("sentinel.json"))
redis = SentinelRedis(config.app.redis_url)
loader = ScriptLoader(redis.client)
guard = SentinelGuard(config, redis, loader)
@asynccontextmanager
async def lifespan(_: FastAPI):
await guard.load_scripts() # required before the first request
yield
app = FastAPI(lifespan=lifespan)
@app.post("/tailor")
async def tailor(
request: Request, _: None = Depends(guard.guard_for("resumint.tailor"))
) -> dict[str, object]:
return {"allowed": request.state.decision.allowed}
What each piece does:
| Line | What happens |
|---|---|
load_config(Path("sentinel.json")) |
Parses + validates the config; bad config is an immediate, loud error |
SentinelRedis(url) |
Creates the client pool with a 20 ms fail-fast budget and runs the noeviction startup check |
ScriptLoader(redis.client) |
Loads and executes the Lua scripts, with NOSCRIPT auto-recovery |
SentinelGuard(config, redis, loader) |
The FastAPI integration; explicit dependencies, no hidden singletons |
await guard.load_scripts() in lifespan |
Required before the first request — forget it and the app raises on startup |
Depends(guard.guard_for("resumint.tailor")) |
The guard dependency; the endpoint_id is the explicit configured id |
The handler receives the decision via request.state.decision — request.state.decision.allowed
is True when the request is admitted. Your business logic goes inside the guarded handler.
What clients must send¶
Authorization: Bearer <JWT> where the JWT is signed with an allowlisted HS* algorithm and
carries both exp and sub. The sub claim is the tenant identity — everything is keyed on
it. The X-Tenant-ID header is ignored (deliberately; spoofing is a locked regression test).
Denied requests map to:
| Situation | Status | Body | Headers |
|---|---|---|---|
| Rate limit exceeded (token bucket or emergency cap) | 429 | {"detail": "rate limit exceeded"} |
Retry-After when computable |
| Store failure on a fail-closed endpoint | 503 | {"detail": "rate limiter unavailable"} |
— |
| Missing/invalid token | 401 | {"detail": "authentication required"} |
WWW-Authenticate: Bearer |
Unknown endpoint_id |
404 | {"detail": "unknown endpoint"} |
— |
6. Generate a JWT and test it¶
PyJWT ships with Sentinel, so token generation is available in your environment:
import time
import jwt
token = jwt.encode(
{"sub": "tenant-alice", "exp": int(time.time()) + 3600},
"dev-only-secret-change-me-0123456789abcdef",
algorithm="HS256",
)
print(token)
Start the app:
And exercise it (curl.exe shows the status line and headers, which is what you want to see
for the 429/401 cases — PowerShell's Invoke-WebRequest throws on non-2xx responses):
$token = "eyJ..." # paste the token printed above
# 1. First call — allowed: 200 with {"allowed": true}
curl.exe -i -X POST http://localhost:8000/tailor -H "Authorization: Bearer $token"
# 2. Drain the bucket — denied: 429 with a Retry-After header
curl.exe -i -X POST http://localhost:8000/tailor -H "Authorization: Bearer $token"
# 3. A second tenant gets its own independent bucket
# (generate a token with "sub": "tenant-bob" and repeat step 1)
# 4. A bad token — 401 before any Redis call, WWW-Authenticate: Bearer header
curl.exe -i -X POST http://localhost:8000/tailor -H "Authorization: Bearer not-a-token"
7. Watch it fail¶
The failure semantics are the heart of the design. With the example config above, stop Redis
and watch each endpoint behave (docker compose stop redis — start it again with
docker compose start redis):
resumint.tailor(fail_open) — requests keep flowing, but the in-process emergency limiter caps them atfallback_rate_per_process_micro(2000 µtokens/s, burst 1 s). Users are never blocked by a store outage; they are limited, not hung.pdftalk.ingest(fail_closed) — stop Redis and requests get 503 in ~20–30 ms. Expensive jobs are never unmetered; traffic resumes automatically when the store recovers.
The circuit breaker also trips OPEN after 5 consecutive failures and short-circuits for 30 s — recovering requests are not penalized (only genuine Redis successes reset it), and a down Redis never makes a request wait beyond the 20 ms socket budget. Full decision table and state machine: failure-handling.md.
8. Observability¶
Every decision is observable out of the box — no extra wiring required:
- Prometheus metrics —
sentinel_decisions_total(counter) andsentinel_evaluate_latency_microseconds(histogram), labeled only byendpoint_id/decision_reason(bounded label sets — no tenant label, no cardinality bomb). The collectors register once on the default registry, so your existingprometheus_clientscrape endpoint picks them up. - Logs — every denied decision emits a WARNING structured log (
logger name: sentinel) withtenant_hash,endpoint_id,decision_reason,latency_micro,breaker_state. The tenant is hashed, never logged raw.
The 8 DecisionReason values: RATE_LIMITED, EMERGENCY_LOCAL_LIMIT, FAIL_CLOSED,
CIRCUIT_OPEN, REDIS_TIMEOUT, REDIS_CONNECTION_ERROR, REDIS_NOSCRIPT_RETRY, ALLOWED.
9. Troubleshooting¶
| Symptom | Cause / fix |
|---|---|
RuntimeError: maxmemory-policy is '...'; Sentinel requires 'noeviction' |
Redis not configured per §3 — set maxmemory-policy noeviction |
RuntimeError: maxmemory is unset; Sentinel requires a bounded memory limit |
Redis has no maxmemory — set a bounded limit (e.g. maxmemory 256mb) |
RuntimeError: scripts not loaded on the first request |
await guard.load_scripts() missing from the FastAPI lifespan (or the lifespan isn't wired) |
ValidationError on load_config |
Strict config: unknown keys, policy-dict keys that don't match endpoint_id, or per-algorithm field mixes are all rejected |
| Requests take ≈20–30 ms and return 503 | The 20 ms fail-fast budget hit: Redis unreachable or saturated. This is the designed failure path, not a bug — check Redis |
| 401 on every request | Wrong jwt_secret (min 32 chars, must match the signing secret), or tokens missing exp/sub |
sub not a string / not valid |
sub must be a valid JWT subject — invalid values are rejected with 401 |
10. Where to go next¶
- Home — the documentation homepage (based on the README)
- Architecture — module map, request journey, clock discipline, invariants
- Failure handling — the resiliency triangle in depth
- Known limitations — read before production (per-process breaker, HS* only, no idempotency keys, ...)
- Benchmark results — measured overhead and failure-path latency
- Project record — the frozen V1 spec and review history
sentinel.example.json— the working example config