Desarrollo backend

Rate Limiting y protección de REST API: estrategias basadas en Redis con ejemplos para Go y Laravel

Ruslan Ismailov Publicado 14 min de lectura
R

1. Por qué es necesario el rate limiting en 2026

Cualquier REST API pública sin restricciones de frecuencia de solicitudes es un blanco abierto. En 2026, los ataques a nivel de aplicación (L7) representan la mayor parte de los incidentes: credential stuffing, scraping, abuso de planes gratuitos y DDoS a través de endpoints legítimos. Los filtros de red son inútiles aquí: el tráfico parece solicitudes HTTP normales.

El rate limiting resuelve varios problemas a la vez: protege el backend de la sobrecarga, reduce los costos de cómputo y tráfico saliente, garantiza una distribución justa de recursos entre usuarios y hace que la monetización de la API sea predecible. Para arquitecturas de microservicios, esto es especialmente crítico: un servicio sobrecargado puede derribar todo el sistema en cascada.

2. Algoritmos de rate limiting: comparación de enfoques

Antes de escribir código, es importante elegir el algoritmo correcto. Cada uno ofrece sus propios compromisos entre precisión, consumo de memoria y simplicidad de implementación.

Fixed Window (ventana fija)

El contador se reinicia cada N segundos. Por ejemplo, 100 solicitudes por minuto. La implementación es trivial, pero hay un defecto serio: en el límite de la ventana es posible un doble burst: 100 solicitudes en los últimos segundos de una ventana y 100 solicitudes en los primeros segundos de la siguiente.

  • Ventajas: consumo mínimo de memoria, implementación sencilla.
  • Desventajas: efecto de borde que duplica el tráfico.

Sliding Window Log (registro de ventana deslizante)

Para cada cliente se almacena una lista de marcas de tiempo de todas las solicitudes. Con cada nueva solicitud, las marcas obsoletas se eliminan y se verifica la longitud de la lista. Método absolutamente preciso.

  • Ventajas: sin efecto de borde, precisión del 100%.
  • Desventajas: el consumo de memoria es proporcional al número de solicitudes en la ventana.

Sliding Window Counter (contador de ventana deslizante)

Un híbrido: se toma el contador de la ventana actual y parte del contador de la ventana anterior, proporcional al tiempo transcurrido. Fórmula: count = current_count + prev_count * (window - elapsed) / window. Buena relación entre precisión y memoria.

  • Ventajas: memoria O(1), buena precisión, sin efecto de borde.
  • Desventajas: es una aproximación, no un valor exacto.

Token Bucket (cubo de tokens)

El cubo se llena de tokens a una velocidad fija hasta un máximo. Cada solicitud consume un token. Si no hay tokens, se rechaza la solicitud. Admite burst: el cliente puede acumular tokens y gastarlos de una vez.

  • Ventajas: soporte natural de burst, intuitivamente comprensible.
  • Desventajas: es necesario almacenar dos valores (cantidad de tokens + tiempo del último relleno).

Leaky Bucket (cubo con fuga)

Las solicitudes se ponen en cola y se procesan a una velocidad constante. Ideal para nivelar el tráfico, pero no es adecuado para APIs interactivas: las solicitudes pueden quedarse bloqueadas en la cola.

  • Ventajas: tráfico saliente uniforme.
  • Desventajas: latencia, la cola requiere memoria.

Conclusión práctica: para la mayoría de las REST APIs públicas, la elección óptima es Sliding Window Counter (equilibrio entre precisión y recursos) o Token Bucket (cuando se necesita burst). Fixed Window es aceptable para servicios internos con bajos requisitos de precisión.

3. Redis como base del rate limiting distribuido

Los contadores en memoria dentro del proceso de la aplicación no funcionan con escalado horizontal: cada instancia cuenta de forma independiente y el límite real se multiplica por el número de pods. Redis resuelve esto con un almacenamiento centralizado y operaciones atómicas.

Capacidades clave de Redis para rate limiting:

  • INCR + EXPIRE: incremento atómico del contador y establecimiento de TTL en una sola operación.
  • Scripts Lua: se ejecutan atómicamente en el lado de Redis, permitiendo implementar lógica compleja sin condiciones de carrera.
  • ZADD / ZRANGEBYSCORE: sorted sets para Sliding Window Log.
  • Pipelines: envío por lotes de comandos para reducir la latencia.

Lua es fundamentalmente importante: sin él, la operación «leer contador → verificar → incrementar» son tres solicitudes separadas entre las que puede ocurrir una condición de carrera. Un script Lua es atómico por definición.

4. Implementación de Sliding Window Counter con Redis + Go

Veamos la implementación completa de un middleware de rate limit para Go usando Redis. El algoritmo: dos claves por cada ventana (actual y anterior), el contador se calcula como una suma ponderada.

// ratelimit/sliding_window.go
package ratelimit

import (
    "context"
    "fmt"
    "math"
    "net/http"
    "strconv"
    "time"

    "github.com/redis/go-redis/v9"
)

const slidingWindowLua = `
local current_key = KEYS[1]
local previous_key = KEYS[2]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local elapsed = now % window
local weight = (window - elapsed) / window

local prev_count = tonumber(redis.call('GET', previous_key) or 0)
local curr_count = tonumber(redis.call('GET', current_key) or 0)

local estimated = math.floor(prev_count * weight + curr_count)

if estimated >= limit then
    return {0, estimated, limit}
end

local new_count = redis.call('INCR', current_key)
if new_count == 1 then
    redis.call('EXPIRE', current_key, window * 2)
end

return {1, estimated + 1, limit}
`

type SlidingWindowLimiter struct {
    client     *redis.Client
    limit      int
    windowSecs int
    script     *redis.Script
}

func NewSlidingWindowLimiter(client *redis.Client, limit, windowSecs int) *SlidingWindowLimiter {
    return &SlidingWindowLimiter{
        client:     client,
        limit:      limit,
        windowSecs: windowSecs,
        script:     redis.NewScript(slidingWindowLua),
    }
}

func (l *SlidingWindowLimiter) Allow(ctx context.Context, key string) (allowed bool, remaining int, err error) {
    now := time.Now().Unix()
    windowStart := now / int64(l.windowSecs)

    currentKey := fmt.Sprintf("rl:%s:%d", key, windowStart)
    previousKey := fmt.Sprintf("rl:%s:%d", key, windowStart-1)

    res, err := l.script.Run(ctx, l.client,
        []string{currentKey, previousKey},
        l.limit, l.windowSecs, now,
    ).Slice()
    if err != nil {
        return false, 0, err
    }

    allowed = res[0].(int64) == 1
    current := int(res[1].(int64))
    remaining = int(math.Max(0, float64(l.limit-current)))
    return allowed, remaining, nil
}

// Middleware para net/http
func (l *SlidingWindowLimiter) Middleware(keyFn func(r *http.Request) string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            key := keyFn(r)
            allowed, remaining, err := l.Allow(r.Context(), key)
            if err != nil {
                // Si Redis falla — dejamos pasar (fail-open) y registramos
                next.ServeHTTP(w, r)
                return
            }

            w.Header().Set("X-RateLimit-Limit", strconv.Itoa(l.limit))
            w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
            w.Header().Set("X-RateLimit-Window", strconv.Itoa(l.windowSecs))

            if !allowed {
                retryAfter := l.windowSecs - int(time.Now().Unix())%l.windowSecs
                w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
                http.Error(w, `{"error":"rate limit exceeded"}`, http.StatusTooManyRequests)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

// Ejemplo de uso
func main() {
    rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
    limiter := NewSlidingWindowLimiter(rdb, 100, 60) // 100 req/min

    mux := http.NewServeMux()
    mux.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte(`{"status":"ok"}`))
    })

    keyFn := func(r *http.Request) string {
        // Límite por IP
        return "ip:" + r.RemoteAddr
    }

    http.ListenAndServe(":8080", limiter.Middleware(keyFn)(mux))
}

El script Lua calcula la suma ponderada e incrementa el contador de forma atómica. El código Go envuelve la lógica en un middleware HTTP, añade los encabezados X-RateLimit-* y devuelve 429 Too Many Requests con el encabezado Retry-After.

5. Implementación de Token Bucket con Redis + Laravel

Laravel tiene un facade RateLimiter integrado, pero para cargas de producción con control preciso es más conveniente implementar Token Bucket directamente a través de Redis. Crearemos un middleware con soporte de burst.

<?php
// app/Http/Middleware/TokenBucketRateLimit.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Symfony\Component\HttpFoundation\Response;

class TokenBucketRateLimit
{
    /**
     * Script Lua de Token Bucket para Redis.
     * KEYS[1] = bucket key
     * ARGV[1] = capacity (máx. tokens)
     * ARGV[2] = refill_rate (tokens/seg)
     * ARGV[3] = now (unix timestamp float)
     * ARGV[4] = cost (costo de la solicitud, normalmente 1)
     */
    private string $luaScript = <<<'LUA'
    local key = KEYS[1]
    local capacity = tonumber(ARGV[1])
    local refill_rate = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])
    local cost = tonumber(ARGV[4])

    local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
    local tokens = tonumber(bucket[1]) or capacity
    local last_refill = tonumber(bucket[2]) or now

    -- Rellenamos tokens proporcionalmente al tiempo transcurrido
    local elapsed = math.max(0, now - last_refill)
    tokens = math.min(capacity, tokens + elapsed * refill_rate)

    if tokens < cost then
        -- Guardamos el estado sin cambios
        redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
        redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) + 10)
        local wait = (cost - tokens) / refill_rate
        return {0, math.floor(tokens), math.ceil(wait)}
    end

    tokens = tokens - cost
    redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
    redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) + 10)
    return {1, math.floor(tokens), 0}
    LUA;

    public function handle(Request $request, Closure $next, int $capacity = 60, int $refillRate = 1): Response
    {
        $key = $this->resolveKey($request);
        $now = microtime(true);

        $result = Redis::eval(
            $this->luaScript,
            1,
            "tb_rl:{$key}",
            $capacity,
            $refillRate,
            $now,
            1
        );

        [$allowed, $remaining, $retryAfter] = $result;

        $response = $allowed
            ? $next($request)
            : response()->json(['error' => 'Too Many Requests'], 429);

        $response->headers->set('X-RateLimit-Limit', $capacity);
        $response->headers->set('X-RateLimit-Remaining', max(0, $remaining));

        if (!$allowed) {
            $response->headers->set('Retry-After', $retryAfter);
        }

        return $response;
    }

    private function resolveKey(Request $request): string
    {
        // Prioridad: API-key > usuario autenticado > IP
        if ($apiKey = $request->header('X-API-Key')) {
            return 'apikey:' . hash('sha256', $apiKey);
        }

        if ($user = $request->user()) {
            return 'user:' . $user->id;
        }

        return 'ip:' . $request->ip();
    }
}

Registramos el middleware en app/Http/Kernel.php o a través de Route::middleware:

// routes/api.php
use App\Http\Middleware\TokenBucketRateLimit;

// 60 tokens, recarga de 1 token/seg (burst hasta 60)
Route::middleware([TokenBucketRateLimit::class . ':60,1'])
    ->group(function () {
        Route::get('/data', [DataController::class, 'index']);
    });

// Endpoint premium: 300 tokens, 5 tokens/seg
Route::middleware([TokenBucketRateLimit::class . ':300,5'])
    ->group(function () {
        Route::get('/premium/data', [PremiumController::class, 'index']);
    });

La configuración de Redis en Laravel (config/database.php) debe usar phpredis o predis. Se recomienda phpredis para producción debido a la menor sobrecarga de serialización.

6. Granularidad de los límites

Una protección efectiva del REST API requiere restricciones en múltiples niveles. Jerarquía práctica:

  • Por IP: protección básica contra ataques anónimos. No es fiable con NAT (redes de oficina), pero es necesaria como primera capa. Clave: rl:ip:1.2.3.4.
  • Por API-key: para integraciones B2B. Permite asignar cuotas individuales. Clave: rl:apikey:sha256(key). Nunca use la clave en bruto en el nombre de la clave Redis.
  • Por usuario: para solicitudes autenticadas. No depende de la IP, funciona al cambiar de red. Clave: rl:user:42.
  • Por endpoint: límites distintos para POST /login (5/min) y GET /catalog (1000/min). Clave: rl:user:42:POST:/login.
  • Límite global del servicio: protección contra sobrecarga independientemente del origen. Clave: rl:global:service-name.

En la práctica se aplica una combinación: primero se verifica el límite global, luego el límite por IP, luego por usuario/clave. Si al menos uno se supera, se devuelve 429.

7. Rate limiting distribuido en Kubernetes

En Kubernetes, cada pod tiene su propio proceso. Los contadores locales son inútiles: con 10 réplicas, el límite real es 10 veces mayor al declarado. Redis Cluster resuelve el problema mediante la centralización del estado.

Puntos clave del despliegue:

  • Redis Sentinel o Redis Cluster: para alta disponibilidad. Sentinel es adecuado para la mayoría de los casos, Cluster para volúmenes superiores a 100K operaciones/seg.
  • Hash tags en las claves: en Redis Cluster, las claves de un mismo cliente deben caer en el mismo slot. Use rl:{user:42}:endpoint: las llaves garantizan el hash por user:42.
  • Connection pooling: en Go use go-redis con un pool de conexiones configurado; en Laravel — phpredis con persistent connections.
  • Fail-open vs fail-closed: si Redis no está disponible, decida de antemano: dejar pasar las solicitudes (fail-open, riesgo de sobrecarga) o bloquearlas (fail-closed, riesgo de caída del servicio). Para APIs públicas habitualmente se elige fail-open con alerta.
# kubernetes/redis-rate-limiter.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: rate-limiter-config
data:
  REDIS_ADDR: "redis-cluster.default.svc.cluster.local:6379"
  RATE_LIMIT_DEFAULT: "100"
  RATE_LIMIT_WINDOW_SEC: "60"
  RATE_LIMIT_BURST: "20"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-service
spec:
  replicas: 5  # Los 5 pods comparten el mismo Redis
  template:
    spec:
      containers:
      - name: api
        envFrom:
        - configMapRef:
            name: rate-limiter-config

8. Encabezados HTTP correctos y protección contra evasión

Los encabezados estándar de rate limiting son importantes para los clientes y la compatibilidad:

  • X-RateLimit-Limit: número máximo de solicitudes en la ventana.
  • X-RateLimit-Remaining: número de solicitudes restantes.
  • X-RateLimit-Reset: Unix timestamp del reinicio del contador (para Fixed/Sliding Window).
  • Retry-After: segundos hasta el próximo intento (obligatorio con 429, regulado por RFC 6585).

Mecanismos adicionales de protección:

  • Whitelist: los servicios internos, el monitoreo y CI/CD quedan excluidos del rate limiting por IP o por un encabezado especial. Impleméntelo mediante una verificación previa a la lógica principal.
  • Burst allowance: Token Bucket soporta burst de forma natural. Para Sliding Window se puede añadir un contador de burst separado con TTL corto.
  • Protección contra IP spoofing: no confíe en X-Forwarded-For sin verificación. Configure los trusted proxies explícitamente (en Laravel — middleware TrustProxies; en Go — X-Real-IP solo desde balanceadores conocidos).
  • Jitter en retry: recomiende a los clientes usar exponential backoff con jitter para evitar una tormenta sincronizada de reintentos tras el levantamiento del bloqueo.

9. Monitoreo y alertas

El rate limiting sin monitoreo es una protección ciega. Es necesario rastrear:

  • Frecuencia de activación de límites (respuestas 429): un aumento repentino es señal de un ataque o un bug en el cliente. Exporte la métrica rate_limit_exceeded_total{key_type, endpoint} a Prometheus.
  • Top de infractores: claves con mayor cantidad de bloqueos en la última hora.
  • Latencia añadida por el rate limiter: la llamada a Redis debe tardar menos de 1ms. Si tarda más, hay un problema de red o de Redis.
  • Uso de memoria de Redis: con tráfico elevado, las claves de rate limiting pueden ocupar un volumen significativo. Monitoree redis_memory_used_bytes.
// Go: métricas de prometheus para rate limiter
var (
    rateLimitHits = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "rate_limit_exceeded_total",
            Help: "Total number of rate limit exceeded events",
        },
        []string{"key_type", "endpoint"},
    )
    rateLimitLatency = prometheus.NewHistogram(
        prometheus.HistogramOpts{
            Name:    "rate_limit_check_duration_seconds",
            Help:    "Duration of rate limit check",
            Buckets: []float64{0.0001, 0.0005, 0.001, 0.005, 0.01},
        },
    )
)

func init() {
    prometheus.MustRegister(rateLimitHits, rateLimitLatency)
}

Configure alertas: si la proporción de respuestas 429 supera el 5% del tráfico total durante 5 minutos, notifique al equipo de inmediato. Si Redis no está disponible durante más de 30 segundos, active una alerta crítica.

10. Cómo elegir el algoritmo para su caso

Resumamos la elección de algoritmo para escenarios reales:

  • REST API pública con diferentes planes: Token Bucket — flexibilidad de burst, fácil configuración de límites individuales por API-key.
  • Protección de endpoints de autenticación (brute force): Sliding Window Counter — precisión sin efecto de borde, bajo consumo de memoria.
  • Microservicios internos: Fixed Window — simplicidad, mínima sobrecarga; el efecto de borde no es crítico.
  • Streaming o webhooks: Leaky Bucket — carga uniforme en los servicios downstream.
  • Protección multinivel (recomendada): Sliding Window Counter por IP (protección general) + Token Bucket por usuario (cuota precisa).

Independientemente del algoritmo elegido, tres reglas permanecen invariables: almacenamiento de estado centralizado (Redis), operaciones atómicas (Lua) y encabezados HTTP correctos para los clientes. El rate limiting en 2026 no es una opción, sino un requisito base para cualquier REST API en producción, especialmente en entornos Kubernetes con escalado horizontal.

Tecnologías

Etiquetas

Ruslan Ismailov

Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →