El paquete golang.org/x/time/rate implementa el algoritmo de Token Bucket, una técnica para controlar la tasa de eventos permitidos en un periodo de tiempo. Este algoritmo funciona mediante un “cubo” de capacidad finita que se llena de “tokens” a una tasa constante; cada operación consume un token, y si el cubo está vacío, la operación debe decidir si espera a que se reponga un token o si falla inmediatamente.
Esta aproximación es superior a los algoritmos de ventana deslizante simples porque permite manejar bursts (ráfagas de peticiones) de forma natural: si el cubo está lleno, el sistema puede procesar una ráfaga de peticiones instantáneamente hasta agotar el inventario, manteniendo luego un promedio constante definido por la tasa de reposición. Debes usar Wait(ctx) cuando implementes mecanismos de backpressure, donde prefieres que el cliente espere (respetando su context.Context) en lugar de rechazar la petición. Por el contrario, usa Allow() para implementar políticas de rechazo inmediato, como un middleware que devuelve un 429 Too Many Requests. Si implementas un limitador global sin cuidado, puedes asfixiar tu servicio; si implementas uno por usuario sin una estrategia de limpieza, tu memoria se llenará de objetos huérfanos.
package main
import (
"context"
"fmt"
"sync"
"time"
"golang.org/x/time/rate"
)
// userStats gestiona el limiter y la última actividad para limpieza de memoria.
type userStats struct {
limiter *rate.Limiter
lastSeen time.Time
}
// RateLimiterManager implementa un limitador por usuario con limpieza automática.
type RateLimiterManager struct {
mu sync.Mutex
users map[string]*userStats
rate rate.Limit
burst int
interval time.Duration
}
func NewRateLimiterManager(r rate.Limit, b int, cleanupInterval time.Duration) *RateLimiterManager {
rlm := &RateLimiterManager{
users: make(map[string]*userStats),
rate: r,
burst: b,
interval: cleanupInterval,
}
// Lanzamos un goroutine para evitar fugas de memoria (memory leaks).
go rlm.cleanup()
return rlm
}
func (rlm *RateLimiterManager) cleanup() {
ticker := time.NewTicker(rlm.interval)
for range ticker.C {
rlm.mu.Lock()
for id, stats := range rlm.users {
// Si el usuario no ha hecho nada en el último intervalo, lo borramos.
if time.Since(stats.lastSeen) > rlm.interval {
delete(rlm.users, id)
}
}
rlm.mu.Unlock()
}
}
func (rlm *RateLimiterManager) GetLimiter(userID string) *rate.Limiter {
rlm.mu.Lock()
defer rlm.mu.Unlock()
if stats, exists := rlm.users[userID]; exists {
stats.lastSeen = time.Now()
return stats.limiter
}
// Creamos un nuevo limiter: 5 tokens por segundo, capacidad de ráfaga de 3.
limiter := rate.NewLimiter(rlm.rate, rlm.burst)
rlm.users[userID] = &userStats{
limiter: limiter,
lastSeen: time.Now(),
}
return limiter
}
func main() {
// Un limitador que permite 2 peticiones/seg con ráfagas de 3.
manager := NewRateLimiterManager(2, 3, 10*time.Minute)
ctx := context.Background()
simulateUser := func(id string, reqs int, block bool) {
limiter := manager.GetLimiter(id)
for i := 0; i < reqs; i++ {
if block {
// Wait bloquea la ejecución hasta que haya un token disponible.
if err := limiter.Wait(ctx); err != nil {
fmt.Printf("[User %s] Error en Wait: %v\n", id, err)
return
}
fmt.Printf("[User %s] Petición %d procesada (Wait)\n", id, i+1)
} else {
// Allow no bloquea; retorna false si no hay tokens.
if limiter.Allow() {
fmt.Printf("[User %s] Petición %d procesada (Allow)\n", id, i+1)
} else {
fmt.Printf("[User %s] Petición %d RECHAZADA (Allow)\n", id, i+1)
}
}
time.Sleep(200 * time.Millisecond) // Simula trabajo de red
}
}
// Escenario 1: Ráfaga permitida por el burst
fmt.Println("--- Escenario 1: Ráfaga con Allow ---")
simulateUser("user-1", 5, false)
// Escenario 2: Espera controlada con Wait
fmt.Println("\n--- Escenario 2: Backpressure con Wait ---")
simulateUser("user-2", 5, true)
}
Desglose del diseño
En el RateLimiterManager, la clave es la gestión de la concurrencia y la memoria. Al usar un sync.Mutex, protegemos el map de usuarios, ya que los mapas en Go no son seguros para escrituras concurrentes. Cada vez que llamamos a GetLimiter, actualizamos lastSeen, lo cual es crítico para el proceso de cleanup.
El método cleanup corre en una goroutine dedicada. Sin este método, si tu aplicación recibe peticiones de millones de IDs únicos (por ejemplo, IPs de clientes distintos), el mapa rlm.users crecería indefinidamente, provocando un out-of-memory (OOM).
En el main, observamos la diferencia fundamental:
1. En el primer escenario (user-1), usamos limiter.Allow(). Verás que las primeras 3 peticiones pasan instantáneamente gracias al burst configurado en rate.NewLimiter(2, 3). A partir de la cuarta, el limiter empezará a rechazar peticiones si no se ha cumplido el tiempo de reposición de tokens.
2. En el segundo escenario (user-2), usamos limiter.Wait(ctx). Aquí, el programa no imprime “RECHAZADA”, sino que la ejecución se pausa. El runtime de Go utiliza un time.Timer internamente para despertar la goroutine justo cuando el bucket tenga un token disponible, optimizando el uso de CPU.
El error frecuente
Un error común al implementar limitadores por usuario es olvidar que rate.Limiter es un objeto con estado que consume memoria. Si implementas un limitador por cada dirección IP sin un mecanismo de expiración, tu servicio es vulnerable a ataques de denegación de servicio (DoS) mediante el agotamiento de memoria. Un atacante puede enviar peticiones desde miles de IPs falsificadas, obligando a tu servidor a crear miles de instancias de rate.Limiter que nunca se eliminan.
Siempre acompaña los mapas de limitadores con una estrategia de expulsión (como el cleanup que vimos) o utiliza una caché con soporte para TTL (Time-To-Live).
N° 219