Rate limiting is an important mechanism for controlling resource utilization and maintaining quality of service. Go elegantly supports rate limiting with goroutines, channels, and tickers.
Basic Rate Limiting
package main
import (
"fmt"
"time"
)
func main() {
// Create requests to rate limit
requests := make(chan int, 5)
for i := 1; i <= 5; i++ {
requests <- i
}
close(requests)
// Rate limiter: 1 request every 200ms
limiter := time.Tick(200 * time.Millisecond)
// Process requests at limited rate
for req := range requests {
<-limiter // Block until next tick
fmt.Println("request", req, time.Now())
}
}
time.Tick() creates a channel that receives a value every 200ms, effectively limiting request processing to 5 per second.
Bursty Rate Limiting
Allow short bursts while maintaining overall rate limit:
func main() {
// Bursty limiter with capacity for 3 immediate requests
burstyLimiter := make(chan time.Time, 3)
// Fill the bucket
for range 3 {
burstyLimiter <- time.Now()
}
// Refill at 200ms intervals
go func() {
for t := range time.Tick(200 * time.Millisecond) {
burstyLimiter <- t
}
}()
// Simulate 5 requests
burstyRequests := make(chan int, 5)
for i := 1; i <= 5; i++ {
burstyRequests <- i
}
close(burstyRequests)
// First 3 requests processed immediately (burst)
// Remaining requests rate-limited
for req := range burstyRequests {
<-burstyLimiter
fmt.Println("request", req, time.Now())
}
}
Buffered channels make excellent token buckets for bursty rate limiting. The buffer size determines the burst capacity.
Rate Limiting Patterns
1. Token Bucket
type TokenBucket struct {
tokens chan struct{}
rate time.Duration
capacity int
ticker *time.Ticker
}
func NewTokenBucket(rate time.Duration, capacity int) *TokenBucket {
tb := &TokenBucket{
tokens: make(chan struct{}, capacity),
rate: rate,
capacity: capacity,
ticker: time.NewTicker(rate),
}
// Fill initial tokens
for i := 0; i < capacity; i++ {
tb.tokens <- struct{}{}
}
// Refill tokens
go func() {
for range tb.ticker.C {
select {
case tb.tokens <- struct{}{}:
default:
// Bucket full
}
}
}()
return tb
}
func (tb *TokenBucket) Allow() bool {
select {
case <-tb.tokens:
return true
default:
return false
}
}
func (tb *TokenBucket) Wait() {
<-tb.tokens
}
func (tb *TokenBucket) Stop() {
tb.ticker.Stop()
}
2. Leaky Bucket
type LeakyBucket struct {
queue chan Request
rate time.Duration
capacity int
}
func NewLeakyBucket(rate time.Duration, capacity int) *LeakyBucket {
lb := &LeakyBucket{
queue: make(chan Request, capacity),
rate: rate,
capacity: capacity,
}
go lb.leak()
return lb
}
func (lb *LeakyBucket) leak() {
ticker := time.NewTicker(lb.rate)
defer ticker.Stop()
for range ticker.C {
select {
case req := <-lb.queue:
req.Process()
default:
// Queue empty
}
}
}
func (lb *LeakyBucket) Add(req Request) bool {
select {
case lb.queue <- req:
return true
default:
return false // Queue full
}
}
3. Sliding Window
type SlidingWindow struct {
mu sync.Mutex
requests []time.Time
window time.Duration
limit int
}
func NewSlidingWindow(window time.Duration, limit int) *SlidingWindow {
return &SlidingWindow{
requests: make([]time.Time, 0, limit),
window: window,
limit: limit,
}
}
func (sw *SlidingWindow) Allow() bool {
sw.mu.Lock()
defer sw.mu.Unlock()
now := time.Now()
cutoff := now.Add(-sw.window)
// Remove old requests
var valid []time.Time
for _, t := range sw.requests {
if t.After(cutoff) {
valid = append(valid, t)
}
}
sw.requests = valid
// Check limit
if len(sw.requests) < sw.limit {
sw.requests = append(sw.requests, now)
return true
}
return false
}
Practical Examples
API Rate Limiter
type APIRateLimiter struct {
limiters map[string]*TokenBucket
mu sync.RWMutex
rate time.Duration
burst int
}
func NewAPIRateLimiter(rate time.Duration, burst int) *APIRateLimiter {
return &APIRateLimiter{
limiters: make(map[string]*TokenBucket),
rate: rate,
burst: burst,
}
}
func (arl *APIRateLimiter) GetLimiter(clientID string) *TokenBucket {
arl.mu.Lock()
defer arl.mu.Unlock()
limiter, exists := arl.limiters[clientID]
if !exists {
limiter = NewTokenBucket(arl.rate, arl.burst)
arl.limiters[clientID] = limiter
}
return limiter
}
func (arl *APIRateLimiter) RateLimitMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientID := r.Header.Get("X-Client-ID")
limiter := arl.GetLimiter(clientID)
if !limiter.Allow() {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
Database Connection Rate Limiter
type DBRateLimiter struct {
semaphore chan struct{}
ticker *time.Ticker
}
func NewDBRateLimiter(maxConcurrent int, queryRate time.Duration) *DBRateLimiter {
rl := &DBRateLimiter{
semaphore: make(chan struct{}, maxConcurrent),
ticker: time.NewTicker(queryRate),
}
// Fill semaphore
for i := 0; i < maxConcurrent; i++ {
rl.semaphore <- struct{}{}
}
return rl
}
func (rl *DBRateLimiter) Query(db *sql.DB, query string) (*sql.Rows, error) {
// Wait for rate limiter
<-rl.ticker.C
// Acquire semaphore
<-rl.semaphore
defer func() { rl.semaphore <- struct{}{} }()
return db.Query(query)
}
Worker Pool with Rate Limiting
func rateLimitedWorkerPool(jobs []Job, workersPerSecond int) {
jobChan := make(chan Job, len(jobs))
rate := time.Second / time.Duration(workersPerSecond)
ticker := time.NewTicker(rate)
defer ticker.Stop()
// Send jobs
for _, job := range jobs {
jobChan <- job
}
close(jobChan)
// Process at limited rate
for job := range jobChan {
<-ticker.C // Wait for next tick
go job.Process()
}
}
HTTP Client with Rate Limiting
type RateLimitedClient struct {
client *http.Client
limiter *TokenBucket
}
func NewRateLimitedClient(requestsPerSecond int) *RateLimitedClient {
return &RateLimitedClient{
client: &http.Client{Timeout: 10 * time.Second},
limiter: NewTokenBucket(time.Second/time.Duration(requestsPerSecond), requestsPerSecond),
}
}
func (rlc *RateLimitedClient) Get(url string) (*http.Response, error) {
rlc.limiter.Wait() // Block until token available
return rlc.client.Get(url)
}
func (rlc *RateLimitedClient) TryGet(url string) (*http.Response, error) {
if !rlc.limiter.Allow() {
return nil, errors.New("rate limit exceeded")
}
return rlc.client.Get(url)
}
Advanced Patterns
Per-User Rate Limiting
type UserRateLimiter struct {
limiters sync.Map // map[userID]*TokenBucket
rate time.Duration
burst int
}
func (url *UserRateLimiter) AllowUser(userID string) bool {
limiter, _ := url.limiters.LoadOrStore(userID,
NewTokenBucket(url.rate, url.burst))
return limiter.(*TokenBucket).Allow()
}
Distributed Rate Limiting (Redis)
type RedisRateLimiter struct {
client *redis.Client
window time.Duration
limit int
}
func (rrl *RedisRateLimiter) Allow(key string) (bool, error) {
now := time.Now().Unix()
pipe := rrl.client.Pipeline()
// Remove old entries
pipe.ZRemRangeByScore(context.Background(), key, "0",
fmt.Sprintf("%d", now-int64(rrl.window.Seconds())))
// Count current requests
countCmd := pipe.ZCard(context.Background(), key)
// Add current request
pipe.ZAdd(context.Background(), key, redis.Z{
Score: float64(now),
Member: fmt.Sprintf("%d", now),
})
// Set expiry
pipe.Expire(context.Background(), key, rrl.window)
_, err := pipe.Exec(context.Background())
if err != nil {
return false, err
}
return countCmd.Val() < int64(rrl.limit), nil
}
Rate Limiting Strategies
| Strategy | Use Case | Pros | Cons |
|---|
| Token Bucket | API rate limiting | Allows bursts | Memory per client |
| Leaky Bucket | Smooth traffic | Consistent rate | May drop requests |
| Fixed Window | Simple limits | Easy to implement | Burst at boundaries |
| Sliding Window | Precise limiting | No boundary bursts | More memory |
Testing Rate Limiters
func TestRateLimiter(t *testing.T) {
limiter := NewTokenBucket(100*time.Millisecond, 5)
defer limiter.Stop()
// Should allow burst of 5
for i := 0; i < 5; i++ {
if !limiter.Allow() {
t.Errorf("Request %d should be allowed", i)
}
}
// 6th request should be denied
if limiter.Allow() {
t.Error("6th request should be denied")
}
// After 100ms, should allow 1 more
time.Sleep(100 * time.Millisecond)
if !limiter.Allow() {
t.Error("Request after refill should be allowed")
}
}
Common Pitfalls
Using time.Tick() Without Stopping
// BAD: Ticker leaks
func badRateLimiter() {
for req := range requests {
<-time.Tick(100 * time.Millisecond) // Creates new ticker each time!
process(req)
}
}
// GOOD: Reuse ticker
func goodRateLimiter() {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for req := range requests {
<-ticker.C
process(req)
}
}
Not Handling Bursts
// BAD: Rejects legitimate bursts
if requestCount > limit {
return ErrRateLimitExceeded
}
// GOOD: Allow bursts with token bucket
if !limiter.Allow() {
return ErrRateLimitExceeded
}
Race Conditions in Counters
// BAD: Race condition
var count int
if count < limit {
count++ // Race!
return true
}
// GOOD: Use atomic or mutex
var count atomic.Int32
if count.Add(1) <= int32(limit) {
return true
}
Best Practices
- Use token buckets for APIs - Allows reasonable bursts
- Implement per-user limits - Prevent one user from affecting others
- Return proper HTTP codes - Use 429 Too Many Requests
- Include retry information - Add Retry-After header
- Monitor rate limit hits - Track when limits are reached
- Test under load - Ensure limiter performs at scale
- Clean up old limiters - Remove inactive user limiters
- Use distributed limiting for scale - Redis/memcached for multi-server
Channel-based rate limiters are very efficient. A token bucket can handle millions of requests per second with minimal overhead.
Benchmarks (approximate)
- Token bucket Allow(): ~50-100 ns
- Sliding window Allow(): ~500-1000 ns
- Redis-based Allow(): ~1-5 ms (network)