A production-style rate limiter written in Go, built as a standalone protective service and wired in front of Tren Den (a FastAPI workout tracker) as reverse-proxy middleware.
Supports two rate-limiting algorithms - token bucket and sliding window counter - each with in-memory and Redis-backed implementations, atomic Lua-scripted operations, graceful degradation on Redis failure, and rate-limit response headers. The algorithm is selectable at runtime via a single env var.
- Token bucket rate limiting (in-memory and Redis-backed)
- Sliding window counter rate limiting (in-memory and Redis-backed)
- Runtime-selectable algorithm (
ALGORITHM=token_bucketorsliding_window) behind a sharedLimiterinterface - Atomic Redis operations via Lua scripting (no race conditions under concurrent load)
- Fail-open behavior on Redis outage (configurable timeout + retry policy)
- Standard rate-limit headers (X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After)
- Reverse-proxy middleware mode for wiring into an existing backend
- Benchmarked against real Redis, sequentially and under concurrent (parallel) load
git clone https://github.com/farron65/ratelimiter.git
cd ratelimiter
cp .env.example .env
# edit .env with your Redis connection details
go run cmd/ratelimiter/main.goNote: Start Redis before running the service (
redis-server, ordocker run -p 6379:6379 redis). If Redis isn't running,REDIS_ADDRwill be unreachable and requests will silently succeed unrated (fail-open behavior) instead of erroring - so it can look like everything's working when nothing is actually being rate-limited.
Your backend (
BACKEND_URL) also needs to be running, or the proxy has nothing to forward allowed requests to.
By default the service proxies requests to whatever BACKEND_URL points at, rate-limiting them before they reach your backend. The algorithm is chosen at startup via ALGORITHM (token_bucket or sliding_window, defaults to token_bucket) - only that algorithm's env vars are read, so the other algorithm's vars can be safely omitted from your environment (e.g. in a Render deployment).
| Env Var | Description | Example |
|---|---|---|
BACKEND_URL |
Backend the proxy forwards allowed requests to | http://127.0.0.1:8000 |
REDIS_ADDR |
Redis connection address | localhost:6379 |
PORT |
Port the ratelimiter listens on | 8080 |
ALGORITHM |
Which algorithm to use: token_bucket or sliding_window |
token_bucket |
AUTH_PATHS |
Comma-separated list of exact paths routed to the auth limiter | /login,/signup,/reset-password |
Token bucket (read when ALGORITHM=token_bucket, or unset)
| Env Var | Description | Example |
|---|---|---|
MAX_TOKENS |
Bucket capacity for general requests | 10 |
REFILL_RATE |
Tokens refilled per second for general requests | 1 |
AUTH_MAX_TOKENS |
Bucket capacity for auth-path requests (/login, /signup, or any other auth path) |
5 |
AUTH_REFILL_RATE |
Tokens refilled per second for auth-path requests | 0.01 |
Sliding window (read when ALGORITHM=sliding_window)
| Env Var | Description | Example |
|---|---|---|
WINDOW_SIZE |
Window length in seconds for general requests | 60 |
LIMIT |
Max requests per window for general requests | 10 |
AUTH_WINDOW_SIZE |
Window length in seconds for auth-path requests | 60 |
AUTH_LIMIT |
Max requests per window for auth-path requests | 5 |
Client → ratelimiter (Go) → Backend (e.g. Tren Den)
|
└── Redis (rate-limit state, atomic via Lua)
-
tokenbucket/- in-memory token bucket implementation -
redisbucket/- Redis-backed token bucket, atomic viascripts/allow.lua -
slidingwindow/- in-memory sliding window counter implementation -
redisslidingwindow/- Redis-backed sliding window counter, atomic viascripts/allow.lua -
cmd/ratelimiter/- HTTP server / reverse proxy entrypoint, pluslimiter.godefining the sharedLimiterinterface both algorithms implement
Token bucket allows smooth, continuous refill and permits controlled bursts. Sliding window counter avoids the "double burst at window boundary" problem that plain fixed-window counting has, by blending a weighted fraction of the previous window's count into the current one - the weight decays linearly from 1.0 right after a window rolls over to 0.0 by the time it ends. This is an approximation (not as exact as a sliding log), but it's O(1) storage per client and cheap to compute, which is why it's a common choice in production rate limiters.
Redis read-compute-write logic runs as a single atomic Lua script (redisbucket/scripts/allow.lua, redisslidingwindow/scripts/allow.lua) rather than as separate Redis calls. I ran a concurrency test and found out a race condition where a 25-token bucket allowed 10 extra requests (25 allowed instead of the expected limit) when hit with concurrent requests using naive check-then-decrement logic. Moving the logic into an atomic Lua script eliminated the overcount entirely, verified by the same test. Both algorithms share this pattern.
If Redis is unreachable, the service fails open - requests are proxied through to the backend rather than blocked - rather than failing closed and taking the whole backend down with it. Allow() uses a 200ms context timeout with reduced retries to fail fast rather than hang. Trade-off: the backend is briefly unprotected during a Redis outage. I went with this decision since imho a rate limiter causing a full outage is a worse failure mode than a rate limiter briefly not rate-limiting. See docs/design-decisions.md for more detail.
| Benchmark | Token Bucket | Sliding Window Counter |
|---|---|---|
BenchmarkAllow (miniredis) |
~148µs/op, 862 allocs/op | ~155µs/op, 874 allocs/op |
BenchmarkAllowRealRedis (local Redis, sequential) |
~198µs/op, 23 allocs/op | ~205µs/op, 23 allocs/op |
BenchmarkAllowRealRedisParallel (local Redis, concurrent, unique keys) |
~34µs/op, 25 allocs/op | ~36µs/op, 25 allocs/op |
- The miniredis benchmark shows a higher alloc count because miniredis rebuilds its Lua virtual environment on every call - that's a quirk of the test tool, not the real overhead
- Sliding window counter runs a small, consistent amount slower than token bucket sequentially (~3-5%), which tracks with it doing slightly more work per call (extra rollover check, extra weighted-average math, an extra field to encode/decode). Under parallel load with unique keys per client, this gap disappears - both are network/round-trip bound rather than compute-bound at that point, so the algorithmic difference gets swallowed by concurrency.
- Parallel benchmarks use a unique IP per request (via an atomic counter) so goroutines aren't contending for the same Redis key - this measures overall throughput rather than single-key lock contention.
- These numbers are all localhost-to-localhost. In production (Render), real network latency between the service and Redis will be higher than shown here.
Verified via PoolStats: under BenchmarkAllowRealRedisParallel (~1mil requests, GOMAXPROCS-wide concurrency), TotalConns stabilized at one connection per concurrent worker and never grew further, Misses stayed flat at that same count for the entire run, and Hits climbed into the hundreds of thousands with zero Timeouts or StaleConns - i.e. the pool opens exactly as many connections as concurrent load requires, then reuses them with no further dials or churn.
- No
X-Forwarded-Forsupport yet (not currently needed by Tren Den, my backend service)
Go · Redis · Lua (embedded scripting)