El rate limiting es esencial para proteger APIs del abuso, asegurar el uso justo de recursos y mantener la estabilidad del sistema. Esta guía completa cubre estrategias de pruebas para rate limiting de API, incluyendo varios algoritmos, manejo de respuestas 429, mecanismos de reintento y patrones de rate limiting distribuido.

Comprendiendo Algoritmos de Rate Limiting

Diferentes algoritmos de rate limiting sirven para diferentes casos de uso:

Algoritmo Token Bucket

Los tokens se agregan a una tasa fija. Cada solicitud consume un token. Cuando el bucket está vacío, las solicitudes son rechazadas.

// token-bucket.js
class TokenBucket {
  constructor(capacity, refillRate) {
    this.capacity = capacity;
    this.tokens = capacity;
    this.refillRate = refillRate; // tokens por segundo
    this.lastRefill = Date.now();
  }

  refill() {
    const now = Date.now();
    const timePassed = (now - this.lastRefill) / 1000;
    const tokensToAdd = timePassed * this.refillRate;

    this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
    this.lastRefill = now;
  }

  consume(tokens = 1) {
    this.refill();

    if (this.tokens >= tokens) {
      this.tokens -= tokens;
      return true;
    }

    return false;
  }

  getAvailableTokens() {
    this.refill();
    return Math.floor(this.tokens);
  }
}

module.exports = TokenBucket;

Probando Token Bucket:

// token-bucket.test.js
const TokenBucket = require('./token-bucket');

describe('Rate Limiting Token Bucket', () => {
  test('debe permitir solicitudes cuando hay tokens disponibles', () => {
    const bucket = new TokenBucket(10, 1);

    for (let i = 0; i < 10; i++) {
      expect(bucket.consume()).toBe(true);
    }

    // La 11ª solicitud debe ser rechazada
    expect(bucket.consume()).toBe(false);
  });

  test('debe rellenar tokens con el tiempo', async () => {
    const bucket = new TokenBucket(5, 2); // 2 tokens por segundo

    // Consumir todos los tokens
    for (let i = 0; i < 5; i++) {
      bucket.consume();
    }

    expect(bucket.consume()).toBe(false);

    // Esperar 3 segundos (debe agregar 6 tokens, limitado a 5)
    await new Promise(resolve => setTimeout(resolve, 3000));

    expect(bucket.getAvailableTokens()).toBe(5);
    expect(bucket.consume()).toBe(true);
  });

  test('debe manejar ráfagas de tráfico', () => {
    const bucket = new TokenBucket(100, 10);

    // Ráfaga de 100 solicitudes
    let successCount = 0;

    for (let i = 0; i < 150; i++) {
      if (bucket.consume()) {
        successCount++;
      }
    }

    expect(successCount).toBe(100);
  });
});

Algoritmo Sliding Window

Rastrea el conteo de solicitudes en una ventana de tiempo deslizante:

// sliding-window.js
class SlidingWindow {
  constructor(limit, windowMs) {
    this.limit = limit;
    this.windowMs = windowMs;
    this.requests = [];
  }

  removeOldRequests() {
    const cutoff = Date.now() - this.windowMs;
    this.requests = this.requests.filter(timestamp => timestamp > cutoff);
  }

  isAllowed() {
    this.removeOldRequests();

    if (this.requests.length < this.limit) {
      this.requests.push(Date.now());
      return true;
    }

    return false;
  }

  getRemainingRequests() {
    this.removeOldRequests();
    return Math.max(0, this.limit - this.requests.length);
  }

  getResetTime() {
    this.removeOldRequests();

    if (this.requests.length === 0) {
      return 0;
    }

    return this.requests[0] + this.windowMs;
  }
}

module.exports = SlidingWindow;

Probando Manejo de Respuestas 429

Implementación de Middleware Express

// rate-limit-middleware.js
const express = require('express');
const SlidingWindow = require('./sliding-window');

const rateLimiters = new Map();

function rateLimitMiddleware(options = {}) {
  const {
    limit = 100,
    windowMs = 60000,
    keyGenerator = (req) => req.ip
  } = options;

  return (req, res, next) => {
    const key = keyGenerator(req);

    if (!rateLimiters.has(key)) {
      rateLimiters.set(key, new SlidingWindow(limit, windowMs));
    }

    const limiter = rateLimiters.get(key);

    if (limiter.isAllowed()) {
      res.setHeader('X-RateLimit-Limit', limit);
      res.setHeader('X-RateLimit-Remaining', limiter.getRemainingRequests());
      res.setHeader('X-RateLimit-Reset', Math.ceil(limiter.getResetTime() / 1000));
      next();
    } else {
      const resetTime = Math.ceil((limiter.getResetTime() - Date.now()) / 1000);

      res.setHeader('Retry-After', resetTime);
      res.setHeader('X-RateLimit-Limit', limit);
      res.setHeader('X-RateLimit-Remaining', 0);
      res.setHeader('X-RateLimit-Reset', Math.ceil(limiter.getResetTime() / 1000));

      res.status(429).json({
        error: 'Demasiadas Solicitudes',
        message: `Límite de tasa excedido. Inténtelo de nuevo en ${resetTime} segundos.`,
        retryAfter: resetTime
      });
    }
  };
}

module.exports = rateLimitMiddleware;

Probando Respuestas 429:

// rate-limit-middleware.test.js
const request = require('supertest');
const express = require('express');
const rateLimitMiddleware = require('./rate-limit-middleware');

describe('Middleware de Rate Limit', () => {
  let app;

  beforeEach(() => {
    app = express();
    app.use(rateLimitMiddleware({ limit: 5, windowMs: 1000 }));
    app.get('/api/test', (req, res) => res.json({ success: true }));
  });

  test('debe permitir solicitudes dentro del límite', async () => {
    for (let i = 0; i < 5; i++) {
      const response = await request(app).get('/api/test');

      expect(response.status).toBe(200);
      expect(response.headers['x-ratelimit-limit']).toBe('5');
      expect(response.headers['x-ratelimit-remaining']).toBeDefined();
    }
  });

  test('debe devolver 429 cuando se excede el límite', async () => {
    // Agotar límite de tasa
    for (let i = 0; i < 5; i++) {
      await request(app).get('/api/test');
    }

    const response = await request(app).get('/api/test');

    expect(response.status).toBe(429);
    expect(response.body.error).toBe('Demasiadas Solicitudes');
    expect(response.headers['retry-after']).toBeDefined();
    expect(response.headers['x-ratelimit-remaining']).toBe('0');
  });

  test('debe incluir header retry-after', async () => {
    for (let i = 0; i < 5; i++) {
      await request(app).get('/api/test');
    }

    const response = await request(app).get('/api/test');

    expect(response.headers['retry-after']).toBeDefined();
    expect(parseInt(response.headers['retry-after'])).toBeGreaterThan(0);
  });

  test('debe reiniciar después de que expire la ventana', async () => {
    // Usar todas las solicitudes
    for (let i = 0; i < 5; i++) {
      await request(app).get('/api/test');
    }

    // Verificar límite de tasa excedido
    let response = await request(app).get('/api/test');
    expect(response.status).toBe(429);

    // Esperar a que se reinicie la ventana
    await new Promise(resolve => setTimeout(resolve, 1100));

    // Debe permitir solicitudes nuevamente
    response = await request(app).get('/api/test');
    expect(response.status).toBe(200);
  });
});

Pruebas de Backoff Exponencial

// exponential-backoff.js
class ExponentialBackoff {
  constructor(options = {}) {
    this.initialDelay = options.initialDelay || 1000;
    this.maxDelay = options.maxDelay || 60000;
    this.factor = options.factor || 2;
    this.jitter = options.jitter !== false;
    this.maxRetries = options.maxRetries || 5;
  }

  async execute(fn, retries = 0) {
    try {
      return await fn();
    } catch (error) {
      if (retries >= this.maxRetries) {
        throw error;
      }

      if (error.response?.status === 429) {
        const retryAfter = error.response.headers['retry-after'];
        let delay;

        if (retryAfter) {
          delay = parseInt(retryAfter) * 1000;
        } else {
          delay = Math.min(
            this.initialDelay * Math.pow(this.factor, retries),
            this.maxDelay
          );

          if (this.jitter) {
            delay = delay * (0.5 + Math.random() * 0.5);
          }
        }

        console.log(`Reintentando después de ${delay}ms (intento ${retries + 1}/${this.maxRetries})`);

        await new Promise(resolve => setTimeout(resolve, delay));

        return this.execute(fn, retries + 1);
      }

      throw error;
    }
  }
}

module.exports = ExponentialBackoff;

Mejores Prácticas de Pruebas de Rate Limiting

Lista de Verificación de Pruebas

  • Probar cada algoritmo de rate limiting (token bucket, sliding window, fixed window)
  • Verificar que respuestas 429 incluyen headers apropiados
  • Probar valores de header Retry-After
  • Validar headers X-RateLimit (Limit, Remaining, Reset)
  • Probar backoff exponencial con jitter
  • Verificar que límites de tasa se reinician correctamente
  • Probar rate limiting distribuido a través de instancias
  • Probar diferentes límites de tasa por usuario/API key
  • Validar manejo de ráfagas de tráfico
  • Probar rate limiting bajo carga concurrente
  • Monitorear impacto de rendimiento del rate limiter

Comparación de Algoritmos

AlgoritmoProsContrasCaso de Uso
Token BucketPermite ráfagas, tasa suaveImplementación complejaAPIs con carga variable
Sliding WindowPreciso, justoMayor uso de memoriaAplicación estricta de tasa
Fixed WindowSimple, bajo overheadProblema de ráfaga en bordesAPIs de alto rendimiento
Leaky BucketSuaviza tasa de salidaRechaza ráfagasSistemas basados en colas

Conclusión

Las pruebas efectivas de rate limiting aseguran que las APIs pueden manejar el abuso, mantener estabilidad y proporcionar retroalimentación clara a los clientes. Al implementar pruebas completas para varios algoritmos, manejo de respuestas 429, backoff exponencial y escenarios distribuidos, puede construir sistemas robustos de rate limiting.

Conclusiones clave:

  • Elegir el algoritmo correcto para su caso de uso
  • Siempre incluir headers Retry-After en respuestas 429
  • Implementar backoff exponencial con jitter en el lado del cliente
  • Usar Redis para rate limiting distribuido
  • Probar límites de tasa bajo condiciones de carga realistas
  • Monitorear métricas de rate limiting en producción

El rate limiting robusto protege sus APIs mientras proporciona una buena experiencia de usuario para clientes legítimos.