Skip to content

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

  1. Prerequisites
  2. Install the library
  3. Set up Redis
  4. Create the configuration file
  5. Wire Sentinel into FastAPI
  6. Generate a JWT and test it
  7. Watch it fail (fail-open vs fail-closed)
  8. Observability
  9. Troubleshooting
  10. 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

pip install sentinel-rate-limiter

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):

pip install -e ".[dev]"

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:

docker compose up -d

The equivalent raw config (if you run Redis yourself, or if you only pip-installed the library and don't have the repo):

maxmemory 256mb
maxmemory-policy noeviction

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):

Copy-Item sentinel.example.json sentinel.json
{
  "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 10000000 is 10 tokens; a refill rate of 10000 is 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.decisionrequest.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:

uvicorn app:app --port 8000

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 at fallback_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 metricssentinel_decisions_total (counter) and sentinel_evaluate_latency_microseconds (histogram), labeled only by endpoint_id/decision_reason (bounded label sets — no tenant label, no cardinality bomb). The collectors register once on the default registry, so your existing prometheus_client scrape endpoint picks them up.
  • Logs — every denied decision emits a WARNING structured log (logger name: sentinel) with tenant_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