runtime/rants

Project

go-redis-token-bucket

shipped

A distributed rate limiter: one limit across N nodes, enforced by an atomic Lua script in Redis.

go · redis · lua

What it is

A minimal demonstration of a distributed token bucket rate limiter built with Go, Redis and a Lua script. Four identical API nodes share a single Redis instance, so a client is limited to its quota across the whole cluster, not per node.

It is a learning exercise, not a library, and the README is explicit that it is a toy: the client identity comes from an unauthenticated X-Client-ID header, and there is no TLS, authentication or Redis replication.

Architecture

┌──────────┐
│ client │ 1000 concurrent requests,
│ (Go) │ randomly spread over 4 ports
└────┬─────┘
┌───────────┬───┴───────┬───────────┐
▼ ▼ ▼ ▼
┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐
│ node1 │ │ node2 │ │ node3 │ │ node4 │
│ :8081 │ │ :8082 │ │ :8083 │ │ :8084 │
└───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘
└───────────┴─────┬─────┴───────────┘
▼
┌─────────────┐
│ Redis │ one bucket per client:
│ │ ratelimit:global:<client-id>
└─────────────┘

The nodes hold no state. Each bucket is a Redis hash with tokens and last_refill, created full on a client’s first request and refilled lazily when a request arrives. A Lua script does read, refill, compare, decrement and write as one atomic operation, called by SHA1 via EVALSHA. The bucket holds 20 tokens (the burst) and refills at 1 per second (the sustained rate). A rejected request gets 429 with Retry-After.

What I learned

  • The check-and-decrement has to be atomic. Without it, two nodes read the same token count and both allow the request: a read-modify-write race that would happen constantly under this load.
  • A token bucket separates sustained rate from burst, which a naive fixed-window counter cannot express.
  • The shared quota holds across nodes. In a typical run of 1000 requests, 16 got 200 OK and 984 got 429 Too Many Requests, spread across all four nodes. Per-node buckets would have allowed 4 × 20 = 80.
  • Failing closed is a policy decision. If Redis is down every request gets a 500; production systems usually fail open or fall back to a local limit.

What’s next