Skip to content
gohacks.dev
APIs

Build an API Rate Limiter Service in Go With Redis

Build a distributed token bucket rate limiter in Go with Redis and a Lua script: atomic across instances, server-side time, HTTP headers and a failure policy.

14 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the Redis logo.

A rate limiter on one server is a map and a mutex. A rate limiter on ten servers is a distributed systems problem: each instance sees a tenth of the traffic, so a per-instance limit of 100 requests a minute turns into 1,000 for the caller who happens to hit all of them, and a load balancer that sprays requests makes any local limit meaningless. The standard answer is shared state, and Redis is the usual place to keep it.

Tested with Go 1.25.6, go-redis v9.17.0, Redis 8, macOS on Apple Silicon (arm64)

This project builds that limiter in Go: a token bucket whose state lives in Redis, a Lua script that makes the decision atomically, HTTP middleware that tells the client where it stands and a failure policy for the day Redis is unavailable. It extends the single-process ideas in rate limiting a Go API and the token bucket limiter, and it is the shared-state version of the limiter filter in the small API gateway. The lab starts a Redis 8 container itself. It ran on Go 1.25.6 with go-redis v9.17.0.

Choosing an algorithm

Several algorithms are in common use.

AlgorithmState per keyBehaviour
Fixed windowA counter and an expirySimple. Allows a burst of twice the limit across a window boundary
Sliding window logA sorted set of timestampsExact. Memory grows with the limit
Sliding window counterTwo countersApproximate, cheap, smooth
Token bucketTokens and a timestampAllows bursts up to capacity, then a steady rate
GCRAA single timestampEquivalent to a token bucket with minimal state

The token bucket is a good default for APIs because it describes what you usually want to say: "a client may burst up to 10 requests, and over time get 5 per second". The state is two numbers. A request costs a token; tokens refill continuously with elapsed time, up to the capacity. Variable cost is easy (an expensive endpoint costs 5 tokens), which fixed windows handle poorly.

Why a script

The limiter must read the current tokens, compute the refill, decide and write the new value, with no other request interleaving. Doing that from the application as separate Redis commands has a race: two instances read the same value, both decide to allow and both write. Optimistic transactions (WATCH) work but retry under contention. A Lua script runs atomically inside Redis, in one round trip, and Redis executes it without interleaving other commands.

Two details of the script matter.

The time comes from Redis. The script calls TIME instead of taking the clock from the caller. Application servers have clocks that drift and jump; if instance A is two seconds ahead, it would compute more elapsed time and grant extra tokens. With one clock, the refill arithmetic is consistent for everyone.

Keys expire. PEXPIRE is set to the time a full refill takes, plus a second. After that long idle, the bucket would be full again anyway, so deleting the key loses nothing and keeps Redis from accumulating a key for every client that ever called.

The code

limiter.goGo
package main

import (
	"context"
	"fmt"
	"math"
	"net/http"
	"strconv"
	"time"

	"github.com/redis/go-redis/v9"
)

// The whole decision runs inside Redis in one script, so it is atomic across every instance of the API.
// State per key: the token count and the time of the last refill. Time comes from Redis (TIME), not from the
// callers, so clock skew between app servers cannot grant extra tokens.
var tokenBucket = redis.NewScript(`
local key      = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate     = tonumber(ARGV[2])   -- tokens per second
local cost     = tonumber(ARGV[3])

local t   = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1000000

local state  = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(state[1])
local ts     = tonumber(state[2])
if tokens == nil then
  tokens = capacity
  ts = now
end

tokens = math.min(capacity, tokens + (now - ts) * rate)

local allowed = 0
local retry   = 0
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
else
  retry = (cost - tokens) / rate
end

redis.call('HSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, math.ceil(capacity / rate * 1000) + 1000)  -- idle keys clean themselves up
return {allowed, tostring(tokens), tostring(retry)}
`)

type Limiter struct {
	rdb      *redis.Client
	capacity int
	rate     float64
	failOpen bool
}

type Result struct {
	Allowed    bool
	Remaining  int
	RetryAfter time.Duration
}

func (l *Limiter) Allow(ctx context.Context, key string) (Result, error) {
	ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) // a limiter must never become the slow part of a request
	defer cancel()
	vals, err := tokenBucket.Run(ctx, l.rdb, []string{"rl:" + key}, l.capacity, l.rate, 1).Slice()
	if err != nil {
		return Result{Allowed: l.failOpen}, err
	}
	tokens, _ := strconv.ParseFloat(vals[1].(string), 64)
	retry, _ := strconv.ParseFloat(vals[2].(string), 64)
	return Result{
		Allowed:    vals[0].(int64) == 1,
		Remaining:  int(math.Floor(tokens)),
		RetryAfter: time.Duration(retry * float64(time.Second)),
	}, nil
}

// Middleware limits by the caller key and tells the client where it stands.
func (l *Limiter) Middleware(keyOf func(*http.Request) string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		res, err := l.Allow(r.Context(), keyOf(r))
		h := w.Header()
		h.Set("RateLimit-Limit", strconv.Itoa(l.capacity))
		if err != nil {
			h.Set("X-RateLimit-Degraded", "true") // Redis is unavailable; failOpen decides what we do
		} else {
			h.Set("RateLimit-Remaining", strconv.Itoa(res.Remaining))
		}
		if !res.Allowed {
			secs := int(math.Ceil(res.RetryAfter.Seconds()))
			h.Set("Retry-After", strconv.Itoa(max(secs, 1)))
			http.Error(w, fmt.Sprintf(`{"error":"rate limit exceeded","retry_after_seconds":%d}`, max(secs, 1)), http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}

Allow calls the script with the capacity, refill rate and cost, bounded by a 100 ms timeout. A limiter must never become the slowest part of a request; if Redis does not answer in 100 ms, the call fails and the failure policy decides. The result carries whether the request is allowed, how many whole tokens remain, and how long to wait if it was denied: the deficit divided by the rate.

go-redis's Script type sends EVALSHA and falls back to EVAL if the script is not cached yet, so after the first call only the hash crosses the network.

What the run shows

The program starts two limiter instances, which stand for two application servers, each with its own client but one shared Redis.

main.goGo
package main

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"sync"
	"sync/atomic"
	"time"

	"github.com/redis/go-redis/v9"
)

// quiet drops go-redis's own connection log lines, which carry timestamps, so the lab output is stable.
type quiet struct{}

func (quiet) Printf(context.Context, string, ...interface{}) {}

func main() {
	redis.SetLogger(quiet{})
	ctx := context.Background()
	rdb := redis.NewClient(&redis.Options{Addr: os.Getenv("REDIS_ADDR")})
	defer rdb.Close()
	rdb.FlushDB(ctx)

	// Two application instances share one Redis. Capacity 10, refill 5 tokens a second.
	instA := &Limiter{rdb: rdb, capacity: 10, rate: 5, failOpen: true}
	instB := &Limiter{rdb: redis.NewClient(&redis.Options{Addr: os.Getenv("REDIS_ADDR")}), capacity: 10, rate: 5, failOpen: true}

	// 1. A burst of 12 requests for one key, alternating between the instances.
	var allowed, denied int
	for i := 0; i < 12; i++ {
		l := instA
		if i%2 == 1 {
			l = instB
		}
		res, err := l.Allow(ctx, "user:ada")
		if err != nil {
			panic(err)
		}
		if res.Allowed {
			allowed++
		} else {
			denied++
			if denied == 1 {
				fmt.Printf("first denial: remaining=%d retry_after=%v\n", res.Remaining, res.RetryAfter.Round(100*time.Millisecond))
			}
		}
	}
	fmt.Printf("burst of 12 across two instances: %d allowed, %d denied\n", allowed, denied)

	// 2. Tokens come back at the refill rate.
	time.Sleep(1 * time.Second)
	allowed = 0
	for i := 0; i < 8; i++ {
		if res, _ := instA.Allow(ctx, "user:ada"); res.Allowed {
			allowed++
		}
	}
	fmt.Printf("after one second at 5 tokens/s: %d of 8 allowed\n", allowed)

	// 3. Keys are independent.
	res, _ := instA.Allow(ctx, "user:bob")
	fmt.Printf("another key is untouched: allowed=%v remaining=%d\n", res.Allowed, res.Remaining)

	// 4. 200 goroutines race for one fresh key; exactly the capacity may pass.
	var ok atomic.Int64
	var wg sync.WaitGroup
	for i := 0; i < 200; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			l := instA
			if i%2 == 1 {
				l = instB
			}
			if res, err := l.Allow(ctx, "user:race"); err == nil && res.Allowed {
				ok.Add(1)
			}
		}()
	}
	wg.Wait()
	fmt.Printf("200 concurrent requests, capacity 10: %d allowed\n", ok.Load())

	// 5. The HTTP middleware and its headers.
	srv := httptest.NewServer(instA.Middleware(func(r *http.Request) string { return "ip:" + r.Header.Get("X-Test-IP") },
		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "ok") })))
	defer srv.Close()
	for i := 1; i <= 12; i++ {
		req, _ := http.NewRequest("GET", srv.URL, nil)
		req.Header.Set("X-Test-IP", "203.0.113.7")
		resp, _ := http.DefaultClient.Do(req)
		resp.Body.Close()
		if i == 1 || i == 10 || i == 11 || i == 12 {
			fmt.Printf("request %2d -> %d limit=%s remaining=%q retry-after=%q\n", i, resp.StatusCode,
				resp.Header.Get("RateLimit-Limit"), resp.Header.Get("RateLimit-Remaining"), resp.Header.Get("Retry-After"))
		}
	}

	// 6. Redis unreachable: fail open or fail closed is a decision, not an accident.
	dead := redis.NewClient(&redis.Options{Addr: "localhost:1", MaxRetries: 0, DialTimeout: 50 * time.Millisecond})
	for _, open := range []bool{true, false} {
		l := &Limiter{rdb: dead, capacity: 10, rate: 5, failOpen: open}
		res, err := l.Allow(ctx, "user:ada")
		fmt.Printf("redis down, failOpen=%-5v -> allowed=%v (error reported: %v)\n", open, res.Allowed, err != nil)
	}
}
Shell
docker run -d --rm --name redis-rl-lab -p 56379:6379 redis:8 >/dev/null
for i in $(seq 1 30); do docker exec redis-rl-lab redis-cli ping >/dev/null 2>&1 && break; sleep 1; done
REDIS_ADDR=localhost:56379 go run .
docker stop redis-rl-lab >/dev/null
Text
first denial: remaining=0 retry_after=200ms
burst of 12 across two instances: 10 allowed, 2 denied
after one second at 5 tokens/s: 5 of 8 allowed
another key is untouched: allowed=true remaining=9
200 concurrent requests, capacity 10: 10 allowed
request  1 -> 200 limit=10 remaining="9" retry-after=""
request 10 -> 200 limit=10 remaining="0" retry-after=""
request 11 -> 429 limit=10 remaining="0" retry-after="1"
request 12 -> 429 limit=10 remaining="0" retry-after="1"
redis down, failOpen=true  -> allowed=true (error reported: true)
redis down, failOpen=false -> allowed=false (error reported: true)

Burst across instances. Twelve requests for the key user:ada, alternating between the instances, gave 10 allowed and 2 denied. The capacity is 10, so the eleventh request is the first denial, and its retry_after is about 200 ms: one token short at 5 tokens a second. Neither instance knew about the other; the shared script made the count right.

Refill. After a one-second pause, eight attempts got 5 allowed. Five tokens a second for a second is five tokens.

Isolation. The key user:bob had full capacity: it allowed and reported 9 remaining. Keys do not interfere.

Concurrency. Two hundred goroutines, half on each instance, raced for a fresh key. Exactly 10 got through. This is the test that matters for a distributed limiter, and it is the one that a naive read-then-write implementation fails.

HTTP. The middleware limits by a key derived from the request (here an IP-like header) and sets RateLimit-Limit and RateLimit-Remaining on every response. The first request showed 9 remaining, the tenth showed 0, and requests 11 and 12 got 429 Too Many Requests with Retry-After: 1. The Retry-After is rounded up to whole seconds, with a minimum of 1, because the header carries integer seconds.

Redis down. Pointed at a port with no server, Allow returned an error quickly. With failOpen true the result was allowed, with false it was denied. Either is a legitimate choice, which is the next section.

Fail open or fail closed

When the limiter cannot reach Redis, you have to pick.

Fail open (allow the request) keeps the API available during a Redis outage, and exposes you to abuse for the duration. It suits most public APIs where a limiter is a protection and not a feature. Set the X-RateLimit-Degraded header, as the middleware does, count the events and alert on them.

Fail closed (deny) protects whatever is behind the limiter and turns a Redis outage into an API outage. It suits cases where the limit protects something that cannot take unbounded load, or a paid quota where over-use costs money.

A third option is a local fallback: when Redis is unavailable, fall back to a per-instance in-memory limiter with the limit divided by the expected number of instances. It is imprecise and keeps both availability and a rough bound.

Whichever you choose, keep the timeout short, and add a circuit breaker so a dead Redis does not cost every request a 100 ms wait (circuit breaker).

Choosing the key

The key decides who is limited. Options in order of preference: the authenticated principal (user id or API key id), then the tenant (multi-tenant APIs), then an IP address for anonymous endpoints. IP addresses are weak: many users share one behind NAT, and attackers rotate them. If you limit by IP, read the address carefully, since X-Forwarded-For is client controlled unless set by your own proxy (gateway covers trusted hops).

Use different limits per route class: a strict one on login and password reset, a generous one on reads. Include the route class in the key (rl:login:ip:...). Apply a global limit per client as well as per-route limits, so an attacker cannot multiply capacity across routes.

A sliding window variant

If you need a hard rule of the form "no more than 100 requests in any 60 second period", a token bucket does not say exactly that: it allows a burst up to capacity and then a steady rate, and the count in any particular window can exceed the nominal rate by the burst size. The exact version keeps one timestamp per request in a sorted set and counts the ones inside the window:

lua
local key    = KEYS[1]
local limit  = tonumber(ARGV[1])
local window = tonumber(ARGV[2])  -- seconds
local t      = redis.call('TIME')
local now    = tonumber(t[1]) + tonumber(t[2]) / 1000000

redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count < limit then
  redis.call('ZADD', key, now, now .. '-' .. ARGV[3])  -- ARGV[3] is a unique request id
  redis.call('PEXPIRE', key, window * 1000)
  return {1, limit - count - 1}
end
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
return {0, 0, tostring(oldest[2] + window - now)}

It is exact and costs memory proportional to the limit per key, so it suits small limits such as login attempts (5 per 15 minutes) and not "10,000 per hour". I have not run this variant in the lab; it follows the same structure as the token bucket script and would be tested with the same concurrency check. The practical middle ground for large limits is a sliding window counter, which keeps two fixed-window counters and weights the previous one by how far into the current window you are.

Tests worth writing

A limiter has a small contract and a few failure modes, and each one is cheap to test against a real Redis.

  1. Capacity. N fresh requests allowed, the N+1th denied. Done in the lab's first block.
  2. Refill. After waiting, the right number of tokens return. Use short windows and generous margins; test the arithmetic with a script that accepts a fake time as an argument if the timing makes tests flaky.
  3. Atomicity. Many goroutines, many instances, exactly capacity allowed. This is the test that justifies the script.
  4. Isolation. One key's exhaustion does not affect another's.
  5. Cost. A request with cost 5 consumes 5 tokens and is denied when only 4 remain, without consuming them.
  6. Expiry. An idle key disappears (TTL is set) and the next request sees a full bucket.
  7. Failure policy. Unreachable Redis honours failOpen in both settings, within the timeout.
  8. Headers. 429 includes Retry-After as an integer of at least 1 and the limit headers appear on success.

Keep these in the same package as the limiter, and have the Redis address come from an environment variable so CI can point at a service container.

When someone is limited

Rate limiting generates support tickets. Make them easy to resolve. The 429 body should say which limit was hit and when to retry, in the same problem format as the rest of the API. The headers let a well-behaved client slow down by itself. For people who cannot, make it visible: an admin endpoint that shows a client's current tokens and recent denials, built on the same Redis keys, answers "why is my integration failing?" in seconds.

Treat sustained denials as a signal. A client that is denied constantly is usually stuck in a retry loop with no backoff, and the best response is to contact them, not to raise the limit. Pair the limiter with the client-side guidance in writing clients with retries: honour Retry-After, back off with jitter, and never retry in a tight loop. A limit with no matching advice for callers creates the load it was meant to prevent.

Costs and scaling

Each check is one script call: a few microseconds inside Redis, plus a network round trip, typically under a millisecond in a data centre. A single Redis comfortably handles tens of thousands of checks a second. Beyond that, shard by key. In Redis Cluster, a script may only touch keys in one hash slot; this script touches one key, so it works, and different clients' keys spread across slots. Watch for hot keys: one very busy client concentrates load on one shard, and a global limit on a single key does the same.

To reduce the load for very high volume, limit locally first with a cheap in-process check that catches obvious abuse, and consult Redis only for traffic that passes. Or take tokens in batches: reserve ten at a time into a local counter and refill from Redis when it runs out, trading accuracy for fewer round trips.

What to add

Headers per the standard. The IETF has a draft for RateLimit header fields, which is where RateLimit-Limit and RateLimit-Remaining come from. It also defines a reset value. Check the current status before adopting it as a promise.

Per-plan limits. Look the capacity and rate up per client (from the client's plan) and pass them to the script. They do not need to be constants in the limiter.

Observability. Count allowed and denied by key class, track the distribution of denials per client and alert when one client is denied for a long time, which often means a bug in its retry loop and not an attack.

Testing. Test with a real Redis, as the lab does (integration tests); a mock cannot tell you whether the Lua script is atomic. Add a test for each failure policy by pointing at a closed port, and a test of the middleware's headers.

The Redis documentation on scripting with Lua explains atomicity guarantees and the TIME command's use in scripts, and the go-redis script documentation covers the EVALSHA handling used here.

Also inDatabases
Taggedproject
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.