Sentinel
Application-aware, tenant-aware, distributed rate limiting for FastAPI, backed by a single dedicated Redis instance and atomic Lua scripts.
Sentinel sits one layer above edge/CDN limiters: it understands tenants and endpoints, not just IPs. Each endpoint gets its own algorithm (token bucket or sliding window), its own capacity and rate, and — crucially — its own failure semantics: an endpoint that must never overrun (expensive OCR compute) fails closed; an endpoint that must never block paying users fails open, but is capped by an in-process emergency limiter so fail-open never means unlimited.
The design survives adversarial review: tenant identity comes only from a validated JWT sub
claim, the Lua scripts use Redis TIME() as their only clock, all arithmetic is integer
microtokens, and every failure resolves to one of 8 bounded DecisionReason values. See
docs/sentinel-project-record.md for the frozen V1 spec and the review history behind it.
Highlights
- Atomic correctness under concurrency — token bucket and sliding window run as Lua scripts
(
sentinel/lua/*.lua), executed atomically by Redis; verified against pure-Python references (25 parity tests) and under 50-coroutine / multi-process races. - The resiliency triangle — failure classification (
sentinel/errors.py), a per-process circuit breaker (sentinel/circuit_breaker.py), and a fail-open emergency limiter (sentinel/emergency.py). A Redis failure never makes a request wait beyond a 20 ms budget, and always resolves to a decision-table row. - Tenant isolation — buckets keyed
sentinel:v1:{sha256(tenant)}:{endpoint_id}:{policy_version}; raw tenant ids never reach Redis keys, logs, or metrics. - Bounded observability —
sentinel_decisions_totalandsentinel_evaluate_latency_microseconds, labeled only byendpoint_id/decision_reason; WARNING structured deny logs carrytenant_hash, never the tenant. - Security posture locked by tests — 23
security-marked regression tests cover spoofing, Lua TTL-only expiry, cardinality bombs, and more.
Installing
pip install sentinel-rate-limiter
Sentinel is a library, not a service: install it into your FastAPI application's environment,
then wire SentinelGuard into your app (quick start below). You still need a dedicated Redis 7
instance configured with noeviction and a bounded maxmemory — Sentinel refuses to start
otherwise. For development, install the library plus its tooling instead:
pip install -e ".[dev]"
Quick start
Requirements: Python ≥ 3.11, a Redis 7 instance configured with noeviction and a bounded
maxmemory (Sentinel refuses to start otherwise).
pip install -e ".[dev]" # library + dev tooling
docker compose up -d # dedicated Redis with the required config
Write a config file (see sentinel.example.json):
{
"app": {
"redis_url": "redis://localhost:6379/0",
"jwt_secret": "dev-only-secret-change-me-0123456789abcdef",
"jwt_algorithm_allowlist": ["HS256"]
},
"policies": {
"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
}
}
}
Wire the guard into FastAPI (this mirrors tests/test_http_integration.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}
That's the whole integration: clients send Authorization: Bearer <JWT> (signed with an
allowlisted HS* algorithm, carrying exp and sub), and Sentinel decides. Denied requests map
to 429 (rate limits, with Retry-After where computable) or 503 (store failures); auth
failures map to 401 before any Redis call.
Configuration reference
SentinelConfig is strict: unknown keys are rejected, policy dict keys must match
Policy.endpoint_id, and per-algorithm parameters are validated (e.g. sliding-window policies
must not carry bucket fields; Lua integer-exactness bounds are enforced at load).
AppConfig 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 deferred to V2) |
Policy field |
Meaning |
|---|---|
endpoint_id |
Explicit configured id (^[a-z0-9._-]+$), never derived from the URL |
algorithm |
token_bucket (needs capacity_micro, refill_rate_micro_per_sec) or sliding_window (needs limit; window_size_micro default 60 s) |
fail_mode |
fail_closed (503 on store failure) or fail_open (emergency limiter, capped) |
fallback_rate_per_process_micro |
Fail-open allowance per process (burst = 1 s of this rate) |
policy_version |
Bumped deliberately when a policy changes |
How it works
One request, six stages — auth → policy resolution → rate limiting → failure/resiliency →
observability. The deep walkthrough lives in docs/architecture.md; the highlights:
- Token bucket (
sentinel/lua/token_bucket.lua) — integer microtokens, refill from RedisTIME(), TTL until the bucket would be full again. Denied requests never write. - Sliding window (
sentinel/lua/sliding_window.lua) —current + previous × (remaining/window), anchored to RedisTIME(), 2-window TTL (expiry is lossless). - Breaker + emergency limiter — OPEN short-circuits before Redis; fail-open falls back to
an in-process token bucket at
fallback_rate_per_process_microthat persists state only on ALLOW. Details indocs/failure-handling.md.
Failure semantics at a glance
| Redis outcome | Fail-open endpoint | Fail-closed endpoint |
|---|---|---|
| Success | Lua result | Lua result |
| Timeout / connection error / NOSCRIPT exhausted | Emergency limiter (429 when capped) | Deny, 503 |
| Breaker OPEN | Emergency limiter (429 when capped) | Deny, 503 |
Full table, classifier mapping, breaker state machine, and measured failure-path latency
(B8/B9 p99 ≈ 22–29 ms, dominated by the 20 ms socket budget): docs/failure-handling.md.
Observability
Every decision increments sentinel_decisions_total and observes
sentinel_evaluate_latency_microseconds, both labeled {endpoint_id, decision_reason} — two
bounded label sets, no tenant label. Every denied decision additionally emits a WARNING log
(logger name: sentinel) with tenant_hash, endpoint_id, decision_reason, latency_micro,
breaker_state.
Known limitations
V1 is deliberately small. Read docs/known-limitations.md before production: single dedicated
Redis instance (noeviction required), 20 ms fail-fast socket budget, no idempotency keys
(ADR-011 — retries after a timeout may double-charge), per-process breaker and emergency
limiter (fail-open allowance scales with instance count), HS* JWT only (JWKS deferred to V2),
JWT replay handled upstream, sliding-window denials carry no Retry-After.
Testing & performance
- 294 tests, 100 % coverage on
sentinel/;security-marked (23) andslow-marked (10, incl. concurrency, multi-process, and failure-injection) suites run as dedicated CI jobs against real Redis. - Phase 14 benchmark harness (
benchmarks/benchmark.py, stdlib-only) with the baseline indocs/benchmark-results.md: with-Sentinel ≈ 5.2× throughput overhead at concurrency 1 (one loopback Redis round trip dominating), breaker short-circuit ≈ 7 µs p50, failure path ≈ the dead-port socket timeout. Single-machine loopback figures, reported as-is — not a throughput guarantee.
docker compose up -d
pytest # full suite (integration tests need real Redis)
pytest -m security # security regression suite
pytest -m slow # concurrency + failure-injection suite
pytest --cov=sentinel --cov-report=term-missing # must stay 100%
mypy sentinel # strict
ruff check . && ruff format --check .
python benchmarks/benchmark.py --smoke
Development
- Trunk-based workflow; short-lived
feat//fix//test//docs/branches, squash-merge PRs. - Strict
mypy --strict, ruff check + format, pre-commit hooks; no comments in code unless asked. - Packaging is proven by tests, not assumptions:
tests/test_packaging.pybuilds the wheel and sdist, asserts their contents (Lua sources,py.typed, notests//benchmarks//examples/leaks) and metadata, runstwine check, and smoke-installs the wheel into a fresh venv. ThepackagingCI job repeats the build and fresh-venv install on every PR and push; thepublishjob uploads to PyPI only onv*tags. - Non-negotiable invariants (Redis
TIME()is the only algorithm clock, integer microtokens, explicitendpoint_id, JWT-only identity, no client-reachable numeric input) — seeAGENTS.mdanddocs/architecture.md§7.
License
MIT — see LICENSE.
Metadata
Release files for sentinel-rate-limiter 1.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| sentinel_rate_limiter-1.0.1.tar.gz | 55.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sentinel_rate_limiter-1.0.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 80.3 kB
Release files / sentinel_rate_limiter-1.0.1.tar.gz
| Download URL | sentinel_rate_limiter-1.0.1.tar.gz |
|---|---|
| Size | 55.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a4656ff474aebc77251e6177dc58a2be1545be7a8bb8416873ba8aa05a2a220c
|
|
BLAKE2b-256 checksum How to use checksums |
a69da083e301cc93b202be30b573b641331a19177f11dde2db6f62bf5b1b2c5a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.13
|
Release files / sentinel_rate_limiter-1.0.1-py3-none-any.whl
| Download URL | sentinel_rate_limiter-1.0.1-py3-none-any.whl |
|---|---|
| Size | 25.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1807bd0c2c66c5c8d4bd42abb02d693c281a3d5f7db5f64fb4451de0a9fe1850
|
|
BLAKE2b-256 checksum How to use checksums |
7edd7455ac8bc02f9ba011f99f82b584de46ac70ef0080c0ccf2e44c8f771af1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.13
|