golang.org/x/time/rate is surprisingly good at tracking time itself, not just counting requests.
Let’s watch a simple rate limiter in action. Imagine we have a web service that can handle 10 requests per second, but we want to cap it at 5 requests per second to avoid overwhelming downstream services.
package main
import (
"context"
"fmt"
"net/http"
"time"
"golang.org/x/time/rate"
)
// requestLimiter is a global rate limiter.
var requestLimiter = rate.NewLimiter(rate.Limit(5), 1) // 5 requests per second, burst of 1
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Allow requests only if the limiter has capacity.
if err := requestLimiter.Wait(r.Context()); err != nil {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
fmt.Fprintf(w, "Hello, you made it!\n")
})
fmt.Println("Server starting on :8080")
http.ListenAndServe(":8080", nil)
}
If you hit this server rapidly (e.g., with curl -L localhost:8080 in a loop), you’ll see "Hello, you made it!" appear, but then you’ll start getting "Rate limit exceeded" responses. The requestLimiter.Wait(r.Context()) call is where the magic happens. It blocks until a token is available in the limiter’s internal bucket, ensuring we don’t exceed the defined rate. The rate.NewLimiter(rate.Limit(5), 1) part sets up a limiter that allows 5 events per second with a burst capacity of 1. This means you could, in theory, get 1 request immediately, followed by subsequent requests being spaced out by at least 200 milliseconds (1/5 seconds).
Now, what if you need to coordinate rate limiting across multiple instances of your Go application, perhaps running in different containers or on different machines? That’s where Redis comes in. The golang.org/x/time/rate package is designed for single-process limiting. For distributed systems, you need a shared state. Redis, with its atomic operations and speed, is a perfect fit.
The core idea when using Redis for rate limiting is to leverage its atomic INCR command and set an expiration time. A common pattern is to track requests within a specific time window, say, the last 60 seconds.
Here’s how you might implement a Redis-backed rate limiter:
package main
import (
"context"
"fmt"
"log"
"net/http"
"time"
"github.com/go-redis/redis/v8"
)
// RedisRateLimiter limits requests using Redis.
type RedisRateLimiter struct {
client *redis.Client
limit int // Max requests allowed
window time.Duration // Time window in seconds
}
// NewRedisRateLimiter creates a new RedisRateLimiter.
func NewRedisRateLimiter(client *redis.Client, limit int, window time.Duration) *RedisRateLimiter {
return &RedisRateLimiter{
client: client,
limit: limit,
window: window,
}
}
// Allow checks if a request is allowed.
func (rl *RedisRateLimiter) Allow(ctx context.Context, key string) (bool, error) {
// Use a pipeline for atomic INCR and EXPIRE operations.
pipe := rl.client.Pipeline()
incr := pipe.Incr(ctx, key)
// Set expiration only if the key is new (INCR returns 1)
// Note: EXPIRE will be executed even if INCR returns > 1.
// A more robust approach might involve checking the INCR result
// and only calling EXPIRE if it's 1, but this is simpler and
// generally safe for rate limiting as EXPIRE is idempotent.
pipe.Expire(ctx, key, rl.window)
_, err := pipe.Exec(ctx)
if err != nil {
return false, fmt.Errorf("redis pipeline error: %w", err)
}
// If the incremented count is less than or equal to the limit, the request is allowed.
return incr.Val() <= int64(rl.limit), nil
}
func main() {
// Connect to Redis
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379", // Replace with your Redis address
})
// Ping Redis to check connection
_, err := rdb.Ping(context.Background()).Result()
if err != nil {
log.Fatalf("Could not connect to Redis: %v", err)
}
// Create a rate limiter allowing 10 requests per 60 seconds per IP address.
limiter := NewRedisRateLimiter(rdb, 10, 60*time.Second)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Use the client's IP address as the key for rate limiting.
clientIP := r.RemoteAddr
// In a proxy environment, you might use 'X-Forwarded-For' header.
// e.g., clientIP = r.Header.Get("X-Forwarded-For")
allowed, err := limiter.Allow(r.Context(), clientIP)
if err != nil {
log.Printf("Error checking rate limit: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if !allowed {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
fmt.Fprintf(w, "Hello, your request was processed!\n")
})
fmt.Println("Server starting on :8080 with Redis rate limiting")
http.ListenAndServe(":8080", nil)
}
The core logic here is in the Allow method. We use a Redis pipeline to execute INCR and EXPIRE atomically. INCR key increments the value associated with key by one and returns the new value. If the key does not exist, it is set to 0 before being incremented to 1. We then immediately call EXPIRE key duration to set a timeout on the key. This ensures that after window seconds, the key will automatically be deleted, resetting the count for that specific client IP. If the count returned by INCR is still within our limit, the request is allowed. Otherwise, it’s denied. The limit is 10, and the window is 60 seconds, meaning a single IP address can make at most 10 requests within any given 60-second period.
One subtlety that trips people up is understanding how the EXPIRE command interacts with INCR. If the key doesn’t exist, INCR creates it with a value of 1, and then EXPIRE sets the TTL. If the key does exist, INCR increments its value and returns it. Importantly, EXPIRE is still called. This means that every request that passes the rate limit will reset the expiration time for that key. This is usually the desired behavior for a sliding window rate limiter, as it means the window is constantly sliding forward as long as requests are being made. If you wanted a fixed window, you’d need a more complex approach involving checking the current time and resetting keys explicitly.
The next hurdle you’ll likely encounter is handling more sophisticated rate limiting strategies, like token bucket implementations distributed across Redis, or dealing with distributed denial-of-service (DDoS) attacks that try to overwhelm your Redis instance itself.