A production-style API gateway sitting in front of multiple backend services, handling authentication, routing, rate limiting, timeouts, retries, circuit breaking, correlation IDs, structured logging, and centralized error handling — so that no individual backend service has to reimplement any of it.
This project deliberately does not build Kubernetes, a service mesh, Consul, Vault, a full OAuth provider, GraphQL, Kafka, Celery, or a frontend. The gateway is the point of the project — the backend services behind it are intentionally minimal stubs.
CLIENT
│
▼
┌──────────────────┐
│ API GATEWAY │
│ │
│ Logging │ (outermost — measures full latency)
│ JWT Auth │
│ Rate Limiting │
│ Routing │
│ Circuit Breaker │
│ Retry │
│ Error Handling │
└────────┬─────────┘
│
┌────────────┼────────────┬────────────┐
▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ User │ │ Order │ │ Payment │ │ Wallet │
│ Service │ │ Service │ │ Service │ │ Service │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
See ARCHITECTURE.md for the full request-lifecycle sequence diagram
and the reasoning behind each design decision.
cp .env.example .env # fill in real values, or keep the defaults for local dev
docker-compose up --buildThen:
curl http://localhost:8000/health
curl http://localhost:8000/health/servicespip install -r requirements.txt
pytesttest_rate_limit.py needs a real reachable Redis to pass — either run
it against the docker-compose Redis, or point REDIS_URL at a local
one.
| Route | Auth required | Forwards to |
|---|---|---|
GET /health |
No | (gateway itself, no downstream call) |
GET /health/services |
No | pings every registered service's /health |
/api/users/* |
Yes | user-service |
/api/orders/* |
Yes | order-service |
/api/payments/* |
Yes | payment-service |
/api/wallet/* |
Yes | wallet-service |
Every /api/* route is handled by one generic route in
app/routers/proxy.py — adding a new downstream service means adding
one entry to settings.services in app/core/config.py, not writing a
new endpoint.
- POST is never retried, regardless of a service's configured
max_retries— retrying a non-idempotent request risks double-charging or double-creating a resource if the original attempt actually succeeded but its response was lost. Seeapp/reliability/retry.py. - Circuit breaking is in-memory and per-process, not distributed via
Redis — a deliberate scope limit, not an oversight, kept simple per
the project's "only build this if it stays clean" guidance. See
app/reliability/circuit_breaker.py. - Rate limiting is per-user (via JWT), falling back to per-IP for
requests without an authenticated identity yet — fairer than pure
per-IP limiting for users sharing a network. See
app/middleware/rate_limiter.py. - The gateway fails closed: any route not explicitly listed in
settings.public_pathsrequires a valid JWT by default. Forgetting to add a new public route is a safe failure (accidental 401); the opposite design would fail unsafely (accidental open endpoint).