Rate limiting as a service.
Token Bucket, Sliding Window, and Fixed Window algorithms backed by Redis atomic operations.
Throttle is a standalone HTTP service that handles rate limiting for any application. Instead of building rate limiting logic into each of your services, you register with Throttle once and call a single endpoint before processing each request.
Any application in any language can use it — Python, Ruby, Node, Java — by making one HTTP call. Throttle handles all the Redis counters, algorithm logic, and distributed state.
1. Fixed Window
Divides time into fixed chunks — windows. Each window gets a fresh counter. Simple and fast, but has an edge case: a user can send double the limit in a short burst at the window boundary (100 requests at 00:59, 100 more at 01:01 — 200 requests in 2 seconds against a 100/min limit).
Window 1 Window 2
00:00 ──── 01:00 01:00 ──── 02:00
[████████░░] [░░░░░░░░░░]
70/100 used 0/100 used
2. Sliding Window
Fixes the boundary spike using a weighted average across the current and previous window. More accurate, same Redis memory footprint as fixed window.
weighted = previous_count × overlap + current_count
If you're 15 seconds into a 60-second window, the previous window contributes 75% of its count to the total. Smooth, accurate, no boundary spikes.
3. Token Bucket
A bucket fills with tokens at a constant rate. Each request consumes one token. If the bucket is empty, the request is rejected. Allows short bursts while enforcing a long-term average rate — this is what AWS, Stripe, and Twilio use.
Capacity: 100 tokens
Refill: 10 tokens/second
Request --> consume 1 token --> allowed
No tokens --> rejected --> retry after N seconds
The token bucket needs three operations: read token count → calculate refill → write new count. If two servers run these simultaneously without atomicity, both read the same count and both allow — one too many requests goes through.
We use a Redis Lua script via EVALSHA. Lua scripts execute atomically on the Redis server side — no other command runs between the read and the write, regardless of how many servers are calling it simultaneously.
WATCH/MULTI/EXEC uses optimistic locking — if another client modifies the key between WATCH and EXEC, the transaction aborts and retries. Under high load this creates a retry storm. Lua scripts never abort — they just run.
Measured with go test -bench=. -benchtime=3s, single Redis connection, on an i5-11300H:
| Algorithm | ns/op | ops/sec |
|---|---|---|
| Fixed Window | 32,180 | ~31,000 |
| Sliding Window | 34,258 | ~29,200 |
| Token Bucket | 45,609 | ~21,900 |
Token bucket is slower because it runs as a Lua script via EVALSHA — one round trip executing read-refill-write atomically — versus the pipelined INCR/GET calls used by fixed and sliding window.
Concurrency correctness verified with go test ./algorithms/... -run Concurrent -v: 200 concurrent requests against shared identifiers never exceed the configured limit/capacity for fixed window and token bucket; sliding window stays within its documented approximation tolerance.
One command, no setup:
git clone https://github.com/Alokxk/Throttle.git
cd Throttle
docker-compose up --buildStarts Postgres, Redis, the server, an operator dashboard, and a Prometheus + Grafana stack. The server applies schema migrations automatically on startup — see Migrations below.
- App:
http://localhost:8080 - Dashboard:
http://localhost:5173— usage stats, rules, and exemptions for a client (paste in an API key from/register); seedashboard/README.md - Grafana:
http://localhost:3000(loginadmin/admin, dashboard pre-provisioned) - Prometheus:
http://localhost:9090
Prerequisites: Go 1.24+, PostgreSQL, Redis
git clone https://github.com/Alokxk/Throttle.git
cd Throttle
cp .env.example .env
# Edit .env with your database credentials.env variables:
PORT=8080
DATABASE_URL=postgresql://postgres:postgres@localhost/throttle?sslmode=disable
REDIS_URL=redis://localhost:6379/0
Create the database (migrations apply automatically on startup):
make createdb
make runSchema migrations live in db/migrations/ and are embedded
directly into the compiled binary (db/migrate.go, via go:embed). The app
applies any pending migrations with
golang-migrate before it starts
accepting traffic — the same mechanism locally, in Docker Compose, and in
Kubernetes, so there's no separate init-script path that can drift out of sync.
For manual control — checking the current version or rolling back a step — install the CLI and use the Makefile targets:
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
make migrate-up # apply all pending migrations
make migrate-down # roll back the last migrationmake run— Start the servermake build— Compile the binarymake test— Run all testsmake migrate-up/make migrate-down— Manually apply/roll back migrations (the server already does this automatically on startup)make docker-up— Start with Docker Composemake docker-down— Stop Docker Compose
- Language: Go 1.24 — standard library HTTP server, no framework
- Rate limit state: Redis — atomic
INCR, sorted sets, Lua scripts - Client storage: PostgreSQL — registration, rules, exemptions
- Migrations: golang-migrate, embedded in the binary, applied automatically on boot
- Algorithms: Fixed window, sliding window, token bucket
Why Go's standard library over Gin/Echo: Forces explicit understanding of how HTTP works. No magic — every line is intentional.
Why Redis for counters: INCR is atomic. Two servers checking the same counter simultaneously will always get consistent results. Sub-millisecond latency keeps rate checks off the critical path.
Why PostgreSQL for clients only: Rules and client data are relational, written once, read often. Redis handles the hot path; PostgreSQL handles the cold path.
Structured logging — every log line is JSON (log/slog) and carries a request_id, also returned as the X-Request-ID response header, so a single request can be traced end to end through the logs.
Metrics — /metrics exposes Prometheus counters and histograms: request latency by path/method/status, in-flight requests, DB connection pool stats. A provisioned Grafana dashboard ships in grafana/ (see Docker Compose above).
Dashboard — a React + Tailwind operator UI in dashboard/: live usage stats, rule and exemption management, authenticated the same way any API caller is (an API key). Thin client over the existing REST endpoints, nothing new on the backend beyond CORS and a /me whoami route.
Load testing — pushed with k6 to the actual breaking point, not just a target RPS. Full methodology, the bottleneck found, and the fix are in loadtest/FINDINGS.md.
Kubernetes — a full deployment to a local kind cluster: readiness/liveness probes, persistent storage for Postgres, Secrets, Ingress, and:
- Autoscaling via KEDA on p95
/checklatency, not CPU — load testing showed the app barely uses CPU even under heavy load, so a default CPU trigger would rarely fire - Centralized logs via Loki + Promtail — every replica's logs in one place, labeled by pod, instead of
kubectl logsper pod
See k8s/README.md for the architecture, setup, and usage.
Health check endpoint. No authentication required. Returns {"status": "ok"}.
curl http://localhost:8080/healthRegister your application and get an API key.
curl -X POST http://localhost:8080/register \
-H "Content-Type: application/json" \
-d '{
"name": "my-app",
"email": "dev@example.com",
"default_algorithm": "sliding_window"
}'{
"client_id": "uuid",
"api_key": "thr_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"default_algorithm": "sliding_window",
"created_at": "2024-01-01T00:00:00Z"
}Check if a request should be allowed or rejected.
curl -X POST http://localhost:8080/check \
-H "X-API-Key: thr_xxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"identifier": "user_123",
"limit": 100,
"window": 60,
"algorithm": "sliding_window"
}'{
"allowed": true,
"remaining": 47,
"reset_at": 1234567890,
"algorithm": "sliding_window",
"warning": false,
"retry_after": 0
}Fields:
identifier— The end-user or entity being rate limited. Required for user-based checks; omit for IP-based checks.limit— Maximum number of requests allowed in the window.window— Window size in seconds. Not applicable when using thetoken_bucketalgorithm.algorithm— Rate limiting algorithm to use:fixed_window,sliding_window, ortoken_bucket.rule— Name of a pre-configured rule. If provided, it overrideslimit,window, andalgorithm.warn_threshold— Decimal between 0 and 1 (default: 0.2). When the fraction of remaining allowance falls below this value, the response includes a warning.refill_rate— Tokens-per-second refill rate for thetoken_bucketalgorithm. Defaults tolimit/60if not specified.
Response headers
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1234567890
X-RateLimit-Algorithm: sliding_window
X-RateLimit-Warning: true (only when warning threshold crossed)
Retry-After: 13 (only when rejected)
Rate limit by IP address — no identifier needed. Throttle extracts the real IP automatically, handling X-Forwarded-For and X-Real-IP headers from proxies.
curl -X POST http://localhost:8080/check/ip \
-H "X-API-Key: thr_xxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"limit": 10,
"window": 60,
"algorithm": "fixed_window"
}'{
"allowed": true,
"identifier": "203.0.113.1",
"remaining": 9,
"reset_at": 1234567890
}Create a reusable rate limit policy. Reference it by name on /check instead of passing limit/window/algorithm every time.
curl -X POST http://localhost:8080/rules \
-H "X-API-Key: thr_xxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"name": "api_default",
"algorithm": "sliding_window",
"limit": 100,
"window": 60
}'Use the rule:
curl -X POST http://localhost:8080/check \
-H "X-API-Key: thr_xxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-d '{"identifier": "user_123", "rule": "api_default"}'List all rules for your account.
curl http://localhost:8080/rules/list \
-H "X-API-Key: thr_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"[
{
"name": "api_default",
"algorithm": "sliding_window",
"limit": 100,
"window": 60
}
]Delete a rule by name.
curl -X DELETE http://localhost:8080/rules/api_default \
-H "X-API-Key: thr_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"Clear a specific identifier's rate limit counter. Useful for testing or giving a user a clean slate after a support interaction.
curl -X POST http://localhost:8080/reset \
-H "X-API-Key: thr_xxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"identifier": "user_123",
"algorithm": "fixed_window"
}'{
"message": "Identifier reset successfully",
"identifier": "user_123",
"keys_deleted": 1
}Whitelist an identifier from rate limiting entirely. Useful for internal services, admin users, or health check bots.
curl -X POST http://localhost:8080/exemptions \
-H "X-API-Key: thr_xxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"identifier": "internal-service",
"reason": "Internal microservice, no rate limiting needed"
}'List all exemptions for your account.
curl http://localhost:8080/exemptions/list \
-H "X-API-Key: thr_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"[
{
"identifier": "internal-service",
"reason": "Internal microservice, no rate limiting needed"
}
]Remove an exemption.
curl -X DELETE http://localhost:8080/exemptions/internal-service \
-H "X-API-Key: thr_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"Usage statistics for your account. Reads from Redis counters — instant regardless of request volume. The client_id is returned when you call /register.
curl http://localhost:8080/stats/550e8400-e29b-41d4-a716-446655440000 \
-H "X-API-Key: thr_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"{
"client_id": "uuid",
"total_checks": 15234,
"allowed": 14891,
"rejected": 343,
"by_algorithm": {
"fixed_window": 8000,
"sliding_window": 6000,
"token_bucket": 1234
}
}Single Redis instance is a single point of failure — every algorithm depends on it, so if it goes down, /check goes down even though the app itself is horizontally scaled and autoscaling behind it. This is a deliberate tradeoff, not an oversight: closing it properly means Redis Sentinel (or Cluster), which in Kubernetes means a StatefulSet with Redis+Sentinel sidecars, hostname-based master/replica role selection, and Sentinel's own config-rewrite-on-failover behavior — real operational complexity that wasn't worth hand-rolling for a single local kind cluster with no actual uptime requirement. A real production deployment would reach for a managed Redis with built-in HA (ElastiCache, Memorystore) instead of self-hosting Sentinel.
Functional tests, not integration tests — Tests run against real local PostgreSQL and Redis. True integration tests would spin up isolated containers per test run using testcontainers-go.
Sliding window is an approximation — Uses a weighted average across two windows rather than tracking every request timestamp. Accurate enough for production use but not mathematically exact.
