La limitación de tasa es una de esas cosas que se sienten resueltas en el momento en que escribes el token bucket y ves pasar la prueba. La mía pasó todas las pruebas que le escribí. En producción, detrás de Caddy, cada visitante del sitio compartía un solo cubo, diez peticiones por minuto, para el sitio entero combinado. No por persona. Total.

el limitador en sí nunca fue el bug
La lógica real del cubo es un token bucket pequeño, en memoria, por clave, nada elaborado, se rellena con el tiempo hasta un burst igual a la tasa por minuto:
func (l *Limiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
b, ok := l.buckets[key]
if !ok {
l.buckets[key] = &bucket{tokens: l.burst - 1, lastSeen: now}
return true
}
elapsed := now.Sub(b.lastSeen).Seconds()
b.tokens += elapsed * l.rate
if b.tokens > l.burst {
b.tokens = l.burst
}
b.lastSeen = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}Ese código es correcto y no ha cambiado. El bug estaba enteramente en lo que se pasa como key.
cómo lo indexaba, y por qué se veía bien en local
La versión ingenua indexa puramente por r.RemoteAddr, la dirección de socket cruda que ve el propio servidor HTTP de Go para la conexión:
// what I shipped first
func limiterKey(r *http.Request) string {
return r.RemoteAddr
}En local, corriendo todo por docker compose up en mi portátil, eso es exactamente la dirección propia del navegador, 127.0.0.1 o cerca, una máquina, un cubo, funciona exactamente como se espera en cada prueba manual que hice.
La topología detrás de Caddy en un despliegue real no es esa. Cada petición al servicio de blog llega a través del contenedor del frontend actuando como proxy inverso interno, él mismo sentado detrás de Caddy, él mismo de cara a internet. RemoteAddr en el servicio de blog es la dirección del contenedor del frontend, para cada visitante, porque eso es genuinamente quién abrió la conexión TCP con el servicio de blog desde su punto de vista. Una clave basada en eso no es por visitante en absoluto, es por salto, y hay exactamente un salto entre "cada visitante en internet" y "el servicio de blog." Un limitador por IP construido sobre esa clave es de facto un limitador global para todo el sitio, y se quedó invisible en cada prueba local porque en local no hay ningún salto de proxy tras el que esconderse en primer lugar, el bug solo existe en la topología contra la que nunca probé directamente.
la respuesta estándar, y la trampa dentro de ella
El arreglo estándar para "la dirección que veo no es el cliente real" es X-Forwarded-For, una cabecera que los proxies añaden con la dirección original del cliente al reenviar una petición. Confiar en ella ciegamente es su propia trampa bien conocida sin embargo: X-Forwarded-For es una cabecera de petición, y cualquier cliente puede ponerla literalmente en lo que quiera antes de que Caddy vea la petición siquiera, incluyendo un valor falso elegido específicamente para colisionar con el cubo de límite de tasa de otra persona, o para esquivar el suyo propio. No puedes simplemente leerla y confiar en ella, tienes que saber qué parte de ella, si acaso alguna, escribió de verdad tu propia infraestructura.
La regla a la que llegué: confiar en exactamente un valor, y solo cuando hay exactamente un valor en el que confiar.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" && !strings.Contains(xff, ",") {
if ip := strings.TrimSpace(xff); ip != "" {
return ip
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}Una cabecera de valor único significa que el propio upstream del servicio de blog (el proxy del frontend, y solo el proxy del frontend) la escribió fresca. Cualquier cosa con una coma dentro, varios saltos encadenados, es exactamente lo que produciría un cliente falsificando la cabecera desde fuera, ya que un cliente real no tiene ningún salto anterior que haya podido añadir algo antes de su propio valor falso, así que esa forma cae de vuelta a RemoteAddr en vez de usarse como clave en absoluto.
la otra mitad del arreglo vive un salto antes
Nada de eso funciona a menos que algo aguas arriba realmente reescriba la cabecera a un único valor confiable en vez de simplemente retransmitir lo que sea que envió el navegador. Ese es el trabajo del proxy del frontend, y lo hace deliberadamente, no por accidente:
const inboundXff = event.request.headers.get('x-forwarded-for');
let clientAddress = '';
try {
clientAddress = event.getClientAddress();
} catch {
// no trustworthy client address for this request (e.g. a direct
// loopback healthcheck bypassing Caddy) - fall through to sending none
}
const forwardedFor = resolveForwardedFor(inboundXff, clientAddress);
if (forwardedFor) headers.set('x-forwarded-for', forwardedFor);resolveForwardedFor toma la última entrada de lo que llegó (la que el propio Caddy añadió, confiable sin importar lo que un cliente haya antepuesto antes en la cadena) o cae de vuelta a la dirección de cliente resuelta propia del frontend, y siempre emite exactamente un valor hacia adelante. La comprobación de valor único del servicio de blog solo se sostiene porque este salto anterior lo garantiza, nunca reenviando una cadena de varios valores él mismo. Dos servicios, dos piezas pequeñas de lógica, y todo el asunto solo funciona de verdad porque cada uno confía en la capa inmediatamente detrás suyo y en nada más atrás que eso.
lo que realmente lo sacó a la luz
No un reporte de un usuario enfadado que estaba siendo limitado injustamente, que es lo que esperaba que terminara señalando esto. Salió durante una pasada de revisión mientras trabajaba en los endpoints de inicio de sesión y cambio de contraseña, rastreando exactamente quién podía ver qué dirección en cada salto, y dándome cuenta de que el número "10 peticiones por minuto" era verdad, solo que verdad para la población equivocada. Una vez que lo dices en voz alta, "cada visitante del sitio comparte un cubo," suena obviamente roto. Llegar ahí requirió realmente dibujar la ruta de la petición salto a salto en vez de confiar en que un limitador de tasa que pasaba sus pruebas unitarias estaba haciendo el trabajo en la topología en la que realmente iba a correr.
el mismo arreglo, dos veces
El servicio de blog no es el único sitio donde vivía exactamente esta forma de bug. El servicio de auth mantiene su propia copia separada del mismo paquete limitador en vez de compartir un módulo con el servicio de blog (toda esta base de código duplica a propósito un puñado de cosas pequeñas así, la struct de claims JWT es otra, en vez de traer un paquete interno compartido para una docena de líneas de código), y sus endpoints de inicio de sesión y cambio de contraseña tenían el problema idéntico de un-salto-detrás-de-Caddy, indexados de la misma manera equivocada por la misma razón. Arreglar uno y no el otro habría significado un endpoint haciendo cumplir un límite real por visitante mientras un endpoint hermano dos servicios más allá se quedaba silenciosamente global. Una vez que entendí la forma real del bug, comprobar si existía en cualquier otro sitio que tocara la misma ruta de petición me llevó unos minutos. Escribir esta entrada llevó mucho más tiempo que cualquiera de los dos arreglos.
la lección aburrida debajo de la interesante
Un limitador de tasa con un token bucket correcto y una clave equivocada no está medio roto, está completamente roto, solo que falla en una dirección que parece éxito desde fuera, las peticiones se permiten o se deniegan, los números se mueven, nada lanza un error. Esa es la forma genuinamente peligrosa que puede tomar este tipo de bug. Un limitador que fallara estrepitosamente se habría detectado la primera vez que alguien tocara el endpoint en local. Uno que protege en silencio el límite equivocado sigue funcionando, hasta que el límite que en realidad debía proteger es golpeado por alguien que no juega limpio.