API Gateways are the critical entry points for modern microservices architectures. They handle rate limiting, authentication, request routing, and metric collection. Doing this at scale requires extremely low-latency components. In this guide, we will build a custom API gateway in Go utilizing Redis to implement a distributed token bucket rate limiter.
Why Go and Redis?
Go's goroutines provide lightweight concurrency, allowing a single server to handle tens of thousands of concurrent connections with minimal memory overhead. Redis, being an in-memory data store, offers sub-millisecond read and write latencies, making it the perfect backend to store rate limit counters across horizontal gateway instances.
Designing the Token Bucket Limiter
The token bucket algorithm allows for bursts of traffic up to a maximum bucket capacity, while steadily refilling tokens at a constant rate. Here is a simple implementation of the rate limiter client in Go utilizing Redis lua scripts to ensure atomic increments:
package main
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
type RateLimiter struct {
client *redis.Client
limit int
burst int
}
const rateLimitScript = `
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local current = tonumber(redis.call('get', key) or "0")
if current + 1 > limit then
return 0
else
redis.call("INCRBY", key, 1)
if current == 0 then
redis.call("EXPIRE", key, 60)
end
return 1
end
`
func (rl *RateLimiter) Allow(ctx context.Context, clientID string) (bool, error) {
key := fmt.Sprintf("ratelimit:%s", clientID)
res, err := rl.client.Eval(ctx, rateLimitScript, []string{key}, rl.limit).Result()
if err != nil {
return false, err
}
return res.(int64) == 1, nil
}
Configuring the Gateway Middleware
Now, let's wire this rate limiter into a standard HTTP middleware in Go. If a client exceeds their limit, we will short-circuit the request and return an HTTP 429 Too Many Requests status code.
func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientID := r.Header.Get("X-Client-ID")
if clientID == "" {
clientID = r.RemoteAddr // Fallback to IP address
}
allowed, err := rl.Allow(r.Context(), clientID)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if !allowed {
w.Header().Set("Retry-After", "60")
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
Conclusion
By shifting rate limiting logic directly onto an in-memory database like Redis, we keep the stateless gateway instances extremely fast. They can easily be scaled horizontally behind a Layer 4 load balancer (like AWS NLB) to handle millions of requests daily with minimal resource overhead.