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
Algoritmo | Pros | Contras | Caso de Uso |
---|---|---|---|
Token Bucket | Permite ráfagas, tasa suave | Implementación compleja | APIs con carga variable |
Sliding Window | Preciso, justo | Mayor uso de memoria | Aplicación estricta de tasa |
Fixed Window | Simple, bajo overhead | Problema de ráfaga en bordes | APIs de alto rendimiento |
Leaky Bucket | Suaviza tasa de salida | Rechaza ráfagas | Sistemas 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.