Back to Writings

Building High-Performance API Gateways with Go and Redis

An in-depth look at implementing rate limiting, routing, and token bucket algorithms at the edge using Go's concurrency model and Redis.

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.