Patrón Rate Limit

7 min read

Publicaste tu API pública. A la semana, un solo cliente — sin mala intención, probablemente un script mal configurado en un loop infinito — te está mandando 500 requests por segundo.

Tu base de datos, diseñada para un tráfico mucho más razonable, empieza a responder lento. Y ese cliente sobrecargado está afectando a todos los demás clientes que sí están usando la API correctamente.

Este es el problema que resuelve el Rate Limiting (Limitación de Tasa): controlar cuántas solicitudes puede hacer un cliente en un período de tiempo determinado, y rechazar el exceso antes de que llegue a saturar tu sistema.


¿Por qué necesitás Rate Limiting?

Hay tres escenarios típicos donde este patrón se vuelve indispensable:

  • Protección contra abuso: clientes mal comportados, intencionales o no, que sobrecargan tu API.
  • Control de costos: si consumís una API de terceros que te factura por llamada (por ejemplo, un servicio de geolocalización o de envío de SMS), limitar tu propio consumo evita facturas sorpresa.
  • Fair use entre clientes (uso justo): en una API multi-tenant (multi-inquilino, varios clientes compartiendo la misma infraestructura), un solo cliente no debería poder degradar la experiencia de los demás.

El algoritmo: Token Bucket

Existen varios algoritmos de rate limiting, pero Token Bucket (Balde de Tokens) es el más usado en producción por su simplicidad y flexibilidad. Funciona así:

  • Cada cliente tiene un “balde” con una capacidad máxima de tokens (por ejemplo, 20).
  • El balde se recarga a un ritmo fijo (por ejemplo, 10 tokens por segundo).
  • Cada solicitud consume 1 token.
  • Si el balde tiene tokens disponibles, la solicitud se procesa. Si está vacío, se rechaza con un error 429 Too Many Requests.

Lo interesante de Token Bucket es que permite ráfagas cortas de tráfico (si el balde estaba lleno) mientras mantiene un límite sostenido en el tiempo — a diferencia de un contador simple por ventana fija, que puede permitir picos abruptos justo en el límite de cada ventana.


El diagrama


Guía paso a paso

Paso 1 — Definir la granularidad del límite
¿El límite es por usuario, por API key, por IP, o global? La mayoría de las APIs reales limitan por API key o por cuenta de cliente, no por IP (las IPs se comparten detrás de NAT corporativos).

Paso 2 — Elegir capacidad y tasa de recarga
Definí cuántos tokens caben en el balde (ráfaga máxima permitida) y cuántos se recargan por segundo (límite sostenido). Estos valores deberían basarse en la capacidad real de tu infraestructura.

Paso 3 — Implementar el bucket por cliente
Cada cliente necesita su propio bucket independiente. En una sola instancia, un mapa en memoria alcanza. En múltiples instancias, necesitás almacenamiento compartido — típicamente Redis.

Paso 4 — Responder con información útil al cliente rechazado
Cuando rechazás una solicitud, incluí headers como Retry-After y X-RateLimit-Remaining para que el cliente sepa cuándo puede reintentar, en lugar de simplemente recibir un error sin contexto.

Paso 5 — Monitorear los rechazos
Un aumento repentino de respuestas 429 puede indicar un ataque, un bug en un cliente, o que tus límites están mal calibrados. Es una métrica que merece su propia alerta.


El código

Implementamos Token Bucket en Java, con recarga basada en tiempo transcurrido (sin necesidad de un hilo de fondo constante recargando tokens).


import lombok.Getter;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

// --- El bucket de un cliente individual ---

class TokenBucket {
    private final int capacity;
    private final double refillRatePerMs;
    private double availableTokens;
    private long lastRefillTimestamp;

    public TokenBucket(int capacity, double tokensPerSecond) {
        this.capacity = capacity;
        this.refillRatePerMs = tokensPerSecond / 1000.0;
        this.availableTokens = capacity;
        this.lastRefillTimestamp = System.currentTimeMillis();
    }

    public synchronized boolean tryConsume() {
        refill();
        if (availableTokens >= 1) {
            availableTokens -= 1;
            return true;
        }
        return false;
    }

    private void refill() {
        long now = System.currentTimeMillis();
        long elapsedMs = now - lastRefillTimestamp;
        double tokensToAdd = elapsedMs * refillRatePerMs;

        if (tokensToAdd > 0) {
            availableTokens = Math.min(capacity, availableTokens + tokensToAdd);
            lastRefillTimestamp = now;
        }
    }

    public synchronized double getAvailableTokens() {
        refill();
        return availableTokens;
    }
}

// --- El rate limiter: un bucket independiente por cliente ---

class RateLimiter {
    private final ConcurrentHashMap<String, TokenBucket> buckets = new ConcurrentHashMap<>();
    private final int capacity;
    private final double tokensPerSecond;

    public RateLimiter(int capacity, double tokensPerSecond) {
        this.capacity = capacity;
        this.tokensPerSecond = tokensPerSecond;
    }

    public boolean isAllowed(String clientId) {
        TokenBucket bucket = buckets.computeIfAbsent(clientId,
                id -> new TokenBucket(capacity, tokensPerSecond));
        return bucket.tryConsume();
    }

    public double getRemainingTokens(String clientId) {
        TokenBucket bucket = buckets.get(clientId);
        return bucket == null ? capacity : bucket.getAvailableTokens();
    }
}

// --- Simulación de un filtro HTTP que aplica el rate limiter ---

class ApiGateway {
    private final RateLimiter rateLimiter;
    private final AtomicLong processedRequests = new AtomicLong(0);
    private final AtomicLong rejectedRequests = new AtomicLong(0);

    public ApiGateway(RateLimiter rateLimiter) {
        this.rateLimiter = rateLimiter;
    }

    public void handleRequest(String clientId, int requestNumber) {
        if (rateLimiter.isAllowed(clientId)) {
            processedRequests.incrementAndGet();
            System.out.println("Request #" + requestNumber + " de " + clientId + " → 200 OK "
                    + "(tokens restantes: " + String.format("%.1f", rateLimiter.getRemainingTokens(clientId)) + ")");
        } else {
            rejectedRequests.incrementAndGet();
            System.out.println("Request #" + requestNumber + " de " + clientId + " → 429 Too Many Requests");
        }
    }

    public void printSummary() {
        System.out.println("\nProcesadas: " + processedRequests.get() + " | Rechazadas: " + rejectedRequests.get());
    }
}

// --- Main ---

public class RateLimitingDemo {

    public static void main(String[] args) throws InterruptedException {
        // 5 tokens de capacidad, recarga de 2 tokens por segundo
        RateLimiter rateLimiter = new RateLimiter(5, 2.0);
        ApiGateway gateway = new ApiGateway(rateLimiter);

        System.out.println("=== Cliente hace 8 requests seguidos (ráfaga) ===");
        for (int i = 1; i <= 8; i++) {
            gateway.handleRequest("client-abc", i);
        }

        gateway.printSummary();

        System.out.println("\n=== Esperando 1.5 segundos para que recargue el bucket ===");
        Thread.sleep(1500);

        System.out.println("\n=== Cliente reintenta ===");
        for (int i = 9; i <= 11; i++) {
            gateway.handleRequest("client-abc", i);
        }

        gateway.printSummary();
    }
}

Output esperado:


=== Cliente hace 8 requests seguidos (ráfaga) ===
Request #1 de client-abc → 200 OK (tokens restantes: 4.0)
Request #2 de client-abc → 200 OK (tokens restantes: 3.0)
Request #3 de client-abc → 200 OK (tokens restantes: 2.0)
Request #4 de client-abc → 200 OK (tokens restantes: 1.0)
Request #5 de client-abc → 200 OK (tokens restantes: 0.0)
Request #6 de client-abc → 429 Too Many Requests
Request #7 de client-abc → 429 Too Many Requests
Request #8 de client-abc → 429 Too Many Requests

Procesadas: 5 | Rechazadas: 3

=== Esperando 1.5 segundos para que recargue el bucket ===

=== Cliente reintenta ===
Request #9 de client-abc → 200 OK (tokens restantes: 2.0)
Request #10 de client-abc → 200 OK (tokens restantes: 1.0)
Request #11 de client-abc → 200 OK (tokens restantes: 0.0)

Procesadas: 8 | Rechazadas: 3

Para tener en cuenta en producción

  • En múltiples instancias, necesitás un store compartido: el ejemplo usa un ConcurrentHashMap en memoria, válido solo con una instancia. Con varias instancias detrás de un balanceador de carga, cada una tendría su propio bucket independiente y el límite real terminaría siendo N veces el configurado. Redis con comandos atómicos (o Lua scripts) es el enfoque estándar para rate limiting distribuido.
  • Bibliotecas de producción: Resilience4j incluye un módulo RateLimiter, y para escenarios distribuidos, Bucket4j con backend en Redis es una de las opciones más usadas en el ecosistema Java.
  • Diferenciá límites por plan de servicio: si tenés clientes free y clientes premium, cada tier (nivel) debería tener su propio bucket con capacidad y tasa distintas.
  • Rate limiting en el API Gateway, no en cada microservicio: aplicarlo en un punto centralizado (Kong, AWS API Gateway, o un gateway propio) evita reimplementar la lógica en cada servicio y da visibilidad unificada del tráfico.

Conclusión

Rate Limiting no protege contra atacantes sofisticados — para eso hay otras herramientas de seguridad — pero sí protege contra el escenario mucho más común: un cliente descuidado, un bug en un script, o simplemente más tráfico legítimo del que tu sistema puede manejar en un momento dado. Es de esos patrones que casi nunca se nota que existe… hasta el día que evita un incidente completo.

Recordá suscribirte aquí para recibir los próximos posts directamente en tu correo.


Referencias:
Designing Data-Intensive Applications — Martin Kleppmann
Resilience4j Documentation — https://resilience4j.readme.io/
Bucket4j — https://github.com/bucket4j/bucket4j

Leave a Reply

Your email address will not be published. Required fields are marked *