Un lunes a las 3am me despertó una alerta. El API de un cliente estaba caído porque alguien estaba scrapeando el catálogo de productos a 800 requests por segundo desde una IP. El Lambda de Next.js estaba saturado, cold starts por todos lados, usuarios reales viendo errores 503. Resolví ese día con un AWS WAF rápido, pero supe que necesitaba algo mejor: rate limiting en el edge, antes de que el tráfico llegue al origin.
Este artículo cubre el diseño que implementé después: rate limiting distribuido usando Lambda@Edge + DynamoDB Global Tables. Funciona cerca del usuario, respeta límites por IP y por usuario autenticado, y no mete latencia a requests legítimos. El costo operacional es bajo y la protección es real.
Las opciones y por qué elegí la que elegí
AWS tiene tres formas principales de hacer rate limiting antes del origin. Cada una tiene trade-offs:
AWS WAF Rate-based Rules.
El más simple. Configuras una regla tipo "bloquear IPs que hagan más de 2000 requests en 5 minutos". Funciona out-of-the-box con CloudFront. El problema: el mínimo es 100 requests en 5 minutos y la ventana es fija de 5 minutos (desde hace poco 1 minuto también). No puedes hacer rate limiting por user ID ni por endpoint específico con granularidad fina.
API Gateway Throttling.
Si tu tráfico pasa por API Gateway, tiene throttling nativo por API key o por ruta. Funciona bien pero no aplica si estás usando CloudFront directo a Lambda o S3. Y no hace rate limiting por usuario autenticado sin pegarse con API Gateway custom authorizers.
Lambda@Edge con storage distribuido.
Más complejo pero más poderoso. Interceptas cada request, consultas un store para saber si el cliente ya superó su límite, decides si rechazar o pasar. El store puede ser DynamoDB, ElastiCache, lo que quieras.
Elegí Lambda@Edge porque necesitaba rate limiting diferenciado: IPs anónimas tienen límites bajos, usuarios autenticados tienen límites más altos, y rutas caras (como reports) tienen límites más agresivos que rutas baratas.
flowchart TB
U[Usuario] --> CF[CloudFront]
CF --> LE[Lambda@Edge viewer-request]
LE --> DDB[DynamoDB Global Table]
DDB -->|Contador actualizado| LE
LE -->|Allow| O[Origin: Lambda Next.js]
LE -->|Block 429| R[Response rechazada]
LE -->|Challenge| C[CAPTCHA page]
El modelo en DynamoDB
Cada entrada es un contador por clave (IP o user) y ventana temporal. Uso ventanas deslizantes de 60 segundos como default.
| PK (cliente + ruta) | SK (timestamp ventana) | count | ttl |
|---|---|---|---|
IP#1.2.3.4#PATH#/api/reports |
202602141430 |
45 | 1739556600 |
USER#u_abc#PATH#/api/reports |
202602141430 |
120 | 1739556600 |
IP#1.2.3.4#PATH#global |
202602141430 |
180 | 1739556600 |
El ttl de DynamoDB borra automáticamente entradas viejas después de 1 hora. Sin mantenimiento manual.
Global Tables es importante porque Lambda@Edge corre en múltiples regiones. Si usas DynamoDB en una sola región, todos los reads/writes desde edge locations de Asia tienen que viajar hasta (digamos) us-east-1. Global Tables replica la tabla en las regiones que elijas y cada Lambda@Edge lee de su réplica local.
CDK para la tabla global
// cdk/lib/rate-limit-stack.ts
import { Stack, StackProps } from "aws-cdk-lib";
import {
TableV2,
AttributeType,
Billing,
ReplicaGlobalSecondaryIndexOptions,
} from "aws-cdk-lib/aws-dynamodb";
import { Construct } from "constructs";
export class RateLimitStack extends Stack {
public readonly tableName: string;
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const table = new TableV2(this, "RateLimitTable", {
tableName: "rate-limits",
partitionKey: { name: "PK", type: AttributeType.STRING },
sortKey: { name: "SK", type: AttributeType.STRING },
timeToLiveAttribute: "ttl",
billing: Billing.onDemand(),
replicas: [
{ region: "eu-west-1" },
{ region: "ap-southeast-1" },
{ region: "sa-east-1" },
],
});
this.tableName = table.tableName;
}
}
Defino us-east-1 como región principal (donde vive Lambda@Edge siempre) y replicas en Europa, Asia y Sudamérica. El costo de replicación es real: pagas por writes en cada región. Pero el beneficio de latencia baja en cada edge compensa para este caso de uso.
El Lambda@Edge
// lambda-edge/rate-limit/index.ts
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
UpdateCommand,
DynamoDBDocumentClient,
} from "@aws-sdk/lib-dynamodb";
import type { CloudFrontRequestEvent, CloudFrontRequestResult } from "aws-lambda";
const client = new DynamoDBClient({
region: process.env.AWS_REGION, // Lambda@Edge corre en la región más cercana al POP
});
const doc = DynamoDBDocumentClient.from(client);
const TABLE = "rate-limits";
type Limits = { perMinute: number; perPath?: Record<string, number> };
const LIMITS: Record<"anonymous" | "authenticated", Limits> = {
anonymous: {
perMinute: 60,
perPath: {
"/api/reports": 5,
"/api/export": 3,
"/api/search": 30,
},
},
authenticated: {
perMinute: 300,
perPath: {
"/api/reports": 30,
"/api/export": 20,
"/api/search": 120,
},
},
};
export const handler = async (
event: CloudFrontRequestEvent
): Promise<CloudFrontRequestResult> => {
const request = event.Records[0].cf.request;
const ip = request.clientIp;
const uri = request.uri;
const userId = extractUserId(request);
const tier = userId ? "authenticated" : "anonymous";
const identity = userId ? `USER#${userId}` : `IP#${ip}`;
// Ventana de 60 segundos, redondeada
const windowTs = Math.floor(Date.now() / 60000);
const windowKey = windowTs.toString();
const ttl = windowTs * 60 + 300; // mantener 5 min para debugging
// Checar límite específico por path si existe
const pathLimit = matchPathLimit(uri, LIMITS[tier]);
if (pathLimit) {
const result = await incrementAndCheck(
`${identity}#PATH#${pathLimit.path}`,
windowKey,
pathLimit.limit,
ttl
);
if (!result.allowed) return rateLimitResponse(result, pathLimit.limit);
}
// Checar límite global
const globalResult = await incrementAndCheck(
`${identity}#PATH#global`,
windowKey,
LIMITS[tier].perMinute,
ttl
);
if (!globalResult.allowed) {
return rateLimitResponse(globalResult, LIMITS[tier].perMinute);
}
// Pasar headers con info del rate limit para el origin
request.headers["x-rate-limit-remaining"] = [
{ key: "X-RateLimit-Remaining", value: String(globalResult.remaining) },
];
return request;
};
async function incrementAndCheck(
pk: string,
sk: string,
limit: number,
ttl: number
): Promise<{ allowed: boolean; count: number; remaining: number }> {
try {
const result = await doc.send(
new UpdateCommand({
TableName: TABLE,
Key: { PK: pk, SK: sk },
UpdateExpression:
"ADD #c :one SET #t = if_not_exists(#t, :ttl)",
ExpressionAttributeNames: { "#c": "count", "#t": "ttl" },
ExpressionAttributeValues: {
":one": 1,
":ttl": ttl,
},
ReturnValues: "UPDATED_NEW",
})
);
const count = Number(result.Attributes?.count ?? 1);
return {
allowed: count <= limit,
count,
remaining: Math.max(0, limit - count),
};
} catch (err) {
// Si DynamoDB falla, dejamos pasar para no bloquear tráfico legítimo
console.error("Rate limit check failed:", err);
return { allowed: true, count: 0, remaining: limit };
}
}
function extractUserId(request: any): string | null {
// Asume que un Lambda@Edge anterior (auth) validó el JWT y puso el user ID en header
return request.headers["x-user-id"]?.[0]?.value || null;
}
function matchPathLimit(
uri: string,
limits: Limits
): { path: string; limit: number } | null {
if (!limits.perPath) return null;
for (const [path, limit] of Object.entries(limits.perPath)) {
if (uri.startsWith(path)) return { path, limit };
}
return null;
}
function rateLimitResponse(
result: { count: number; remaining: number },
limit: number
): CloudFrontRequestResult {
return {
status: "429",
statusDescription: "Too Many Requests",
headers: {
"content-type": [{ key: "Content-Type", value: "application/json" }],
"retry-after": [{ key: "Retry-After", value: "60" }],
"x-ratelimit-limit": [
{ key: "X-RateLimit-Limit", value: String(limit) },
],
"x-ratelimit-remaining": [
{ key: "X-RateLimit-Remaining", value: "0" },
],
},
body: JSON.stringify({
error: "rate_limit_exceeded",
message: "Too many requests. Try again in 60 seconds.",
retry_after: 60,
}),
};
}
Lo clave: si DynamoDB falla, dejo pasar la request. Nunca bloqueo tráfico legítimo por un fallo de infra. Monitoreo con CloudWatch y ajusto si es necesario.
Fail-open vs fail-closed
Esta es la decisión más importante y más debatida. ¿Qué hacer si el store de rate limits no responde?
Fail-open (lo que uso arriba): en caso de error, permitir la request. Prioriza UX de usuarios legítimos. Riesgo: un atacante podría saturar DynamoDB (cosa poco probable pero posible) para evadir rate limits.
Fail-closed: en caso de error, bloquear la request. Prioriza seguridad. Riesgo: un fallo operacional tumba tu aplicación.
Elegí fail-open para la mayoría de rutas y fail-closed solo para rutas críticas (ej: /api/admin/*). En Lambda@Edge diferencio así:
const criticalPaths = ["/api/admin/", "/api/billing/"];
const isCritical = criticalPaths.some((p) => uri.startsWith(p));
// En caso de error de DynamoDB
if (isCritical) {
return { status: "503", body: "Service temporarily unavailable" };
}
return request; // fail-open para rutas normales
Response headers informativos
Los clientes serios (apps móviles, integraciones) necesitan saber cuántas requests les quedan. El estándar informal es:
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 245
X-RateLimit-Reset: 1739556660
Los inyecto en el response del origin via viewer-response Lambda@Edge. No los pongo en viewer-request porque ahí solo tengo el contador post-increment.
// lambda-edge/rate-limit-response/index.ts
export const handler = async (event: CloudFrontResponseEvent) => {
const response = event.Records[0].cf.response;
const request = event.Records[0].cf.request;
const remaining = request.headers["x-rate-limit-remaining"]?.[0]?.value;
if (remaining) {
response.headers["x-ratelimit-remaining"] = [
{ key: "X-RateLimit-Remaining", value: remaining },
];
}
return response;
};
Costos reales
Este setup con DynamoDB Global Tables en 4 regiones y Lambda@Edge procesando 50M requests/mes:
| Componente | Costo mensual |
|---|---|
| Lambda@Edge invocations (50M) | $30 |
| Lambda@Edge compute (50M x 20ms x 128MB) | $16 |
| DynamoDB writes (50M x 4 regiones replica) | $125 |
| DynamoDB reads (incluidos en updates) | $0 |
| DynamoDB storage (~5GB promedio) | $5 |
| Total | $176 |
No es barato. Pero compáralo con el costo de un incidente: el scraping que me levantó a las 3am consumió $400 en Lambda cold starts en 6 horas, más 4 horas de un CTO reactivo, más el churn de usuarios que vieron errores. El rate limit se paga solo la primera vez que para un abuso real.
Para apps más chicas, puedes simplificar. Con 5M requests/mes y 1 región, baja a ~$30/mes.
Patrones avanzados
Whitelisting de IPs conocidas.
Para partners o integraciones legítimas que necesitan alto throughput, mantengo una tabla de IPs whitelisted:
const whitelist = await getWhitelistFromCache();
if (whitelist.has(ip)) {
return request;
}
Uso un cache in-memory en el Lambda@Edge que se refresca cada 5 minutos desde DynamoDB o un S3 JSON.
Tiered rate limits.
Usuarios en plan Free tienen 100 req/min, Pro tienen 1000, Enterprise tienen 10000. El Lambda@Edge consulta un cache del plan del usuario (que viene en el JWT):
const plan = request.headers["x-user-plan"]?.[0]?.value || "free";
const limit = PLAN_LIMITS[plan] || PLAN_LIMITS.free;
CAPTCHA en lugar de bloqueo.
Para IPs sospechosas (no claramente maliciosas pero sobre el límite), en vez de 429 devuelvo un redirect a una página con CAPTCHA. Si resuelven, emito un token temporal que los whitelistea por 30 minutos.
if (!allowed && count < limit * 2) {
return {
status: "302",
headers: { location: [{ key: "Location", value: "/captcha?redirect=" + uri }] }
};
}
Lo que aprendí operando esto
1. Las ventanas fijas tienen bordes ruidosos.
Si el límite es 60/min y la ventana resetea en el minuto 0, un cliente que haga 60 requests en el segundo 55 y otras 60 en el segundo 05 del siguiente minuto, hizo 120 en 10 segundos. La ventana fija lo permite. Ventanas deslizantes son más justas pero más caras de calcular. Para la mayoría de casos, las fijas están bien.
2. El contador puede crecer más allá del límite.
Como uso ADD atómico, el contador sigue incrementando aunque ya superó el límite. Eso está OK (solo bloqueas pasado el límite), pero puede confundir si miras DynamoDB directamente y ves valores como count: 847 cuando el límite es 60. Para dashboards, el max value real es menos importante que "cuántos clientes superaron el umbral".
3. Clock skew entre edge locations es mínimo pero existe.
Los edge locations de AWS sincronizan tiempo con NTP pero puede haber diferencias de subsegundo. Para ventanas de 60s no importa, pero si haces ventanas de 1s verás comportamiento extraño.
4. El cold start de Lambda@Edge está mejor pero aún existe.
Mi Lambda de rate limit pesa 2MB con el SDK de DynamoDB pruned. Cold starts son de 180-250ms. Para requests que esperan sub-100ms, eso mata la UX. Solución: Lambda@Edge tiene provisioned concurrency limitada pero real; para rutas críticas, vale la pena configurarla.
5. Observability es esencial.
Sin dashboards, no sabes si tu rate limit funciona. Emito métricas custom a CloudWatch: requests bloqueadas, requests permitidas, por tier, por path. El dashboard me muestra la tasa de bloqueos en tiempo real, y si veo un spike, puedo correlacionar con el tráfico.
6. El rate limit no reemplaza WAF.
Aún tengo WAF delante de CloudFront con reglas básicas: bloqueo de SQL injection, XSS, paths maliciosos, geo-blocking de regiones donde no operamos. WAF se ocupa de "malos conocidos" en protocolo, mi Lambda@Edge se ocupa de "tráfico legítimo pero excesivo". Son capas distintas.
Cuándo NO implementar esto
Si tu app tiene tráfico bajo (menos de 100K requests/día), AWS WAF rate-based rules con la configuración mínima son suficientes y gratis hasta cierto umbral.
Si tu arquitectura no incluye CloudFront, Lambda@Edge no aplica. Tendrías que implementar rate limit en tu API Gateway o en tu backend directamente, con un store central (ElastiCache Redis).
Si tu principal amenaza son ataques DDoS serios (gbps), rate limiting no te salva. Necesitas AWS Shield Advanced con sus mitigaciones especializadas. Rate limiting ayuda contra scraping y abuso de API, no contra attacks volumétricos.
Si no tienes equipo para operar Lambda@Edge (actualizaciones, debugging, monitoring), la complejidad puede ser mayor al beneficio. Empieza con WAF y sube la escalera solo si el beneficio justifica.
El próximo artículo cubre Server Actions de Next.js en Lambda. Formularios sin JavaScript, progressive enhancement, y por qué esto cambia cómo construyo aplicaciones.
Top comments (0)