Cuando tu aplicación genera PDFs o capturas de pantalla a escala, inevitablemente alcanzarás los límites de tasa de la API. Entender cómo funciona el rate limiting — y diseñar tu aplicación para manejarlo con gracia — es la diferencia entre un sistema de producción robusto y uno que falla bajo carga. Esta guía cubre conceptos de rate limiting, estrategias de procesamiento por lotes, gestión de colas y patrones de manejo de errores.
Entendiendo los Rate Limits
Los rate limits protegen tanto al proveedor de la API como a sus usuarios. Aseguran una asignación justa de recursos e impiden que un solo cliente sobrecargue el servicio:
- Solicitudes por segundo (RPS) — ej: 10 solicitudes/segundo
- Solicitudes por minuto (RPM) — ej: 100 solicitudes/minuto
- Solicitudes concurrentes — ej: 5 solicitudes simultáneas
- Cuota mensual — ej: 10,000 renderizaciones/mes
TongoRender retorna información de rate limit en los encabezados de respuesta:
X-RateLimit-Limit: 100 // Máximo de solicitudes por ventana
X-RateLimit-Remaining: 73 // Solicitudes restantes en la ventana actual
X-RateLimit-Reset: 1679529600 // Timestamp Unix cuando la ventana se reinicia
Retry-After: 30 // Segundos a esperar (solo en respuestas 429)
Implementando Clientes Conscientes de Rate Limit
class RateLimitedClient {
constructor(apiKey, maxConcurrent = 5) {
this.apiKey = apiKey;
this.maxConcurrent = maxConcurrent;
this.activeRequests = 0;
this.remaining = Infinity;
this.resetTime = 0;
}
async request(endpoint, body) {
if (this.remaining <= 1) {
const waitTime = (this.resetTime * 1000) - Date.now();
if (waitTime > 0) {
console.log(`Rate limit alcanzado. Esperando ${Math.ceil(waitTime / 1000)}s...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}
while (this.activeRequests >= this.maxConcurrent) {
await new Promise(resolve => setTimeout(resolve, 100));
}
this.activeRequests++;
try {
const response = await fetch(`https://api.tongorender.io/v1/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiKey },
body: JSON.stringify(body),
});
this.remaining = parseInt(response.headers.get('X-RateLimit-Remaining') || '100');
this.resetTime = parseInt(response.headers.get('X-RateLimit-Reset') || '0');
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '30');
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
return this.request(endpoint, body);
}
return response;
} finally {
this.activeRequests--;
}
}
}
Estrategias de Procesamiento por Lotes
Estrategia 1: Concurrencia Controlada
const pLimit = require('p-limit');
const limit = pLimit(5);
async function batchGenerate(items) {
const results = await Promise.allSettled(
items.map(item => limit(() => generatePDF(item)))
);
const succeeded = results.filter(r => r.status === 'fulfilled');
const failed = results.filter(r => r.status === 'rejected');
console.log(`Completados: ${succeeded.length}/${items.length}`);
return { succeeded, failed };
}
Estrategia 2: Procesamiento en Chunks
async function processInChunks(items, chunkSize = 10, delayMs = 2000) {
const results = [];
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
console.log(`Procesando chunk ${Math.floor(i / chunkSize) + 1}`);
const chunkResults = await Promise.allSettled(chunk.map(item => generatePDF(item)));
results.push(...chunkResults);
if (i + chunkSize < items.length) {
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
return results;
}
Estrategia 3: Procesamiento Basado en Cola
const { Queue, Worker } = require('bullmq');
const pdfQueue = new Queue('pdf-generation', { connection });
async function queuePDFGeneration(items) {
const jobs = items.map(item => ({
name: 'generate-pdf',
data: item,
opts: { attempts: 3, backoff: { type: 'exponential', delay: 5000 } },
}));
await pdfQueue.addBulk(jobs);
}
const worker = new Worker('pdf-generation', async (job) => {
const pdf = await generatePDF(job.data);
await savePDF(pdf, job.data.id);
}, { connection, concurrency: 5, limiter: { max: 10, duration: 1000 } });
Manejo de Errores y Reintentos
async function withRetry(fn, maxRetries = 3, baseDelay = 1000) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
const isRetryable = error.status === 429 || error.status >= 500;
if (!isRetryable || attempt === maxRetries) throw error;
const delay = baseDelay * Math.pow(2, attempt - 1) + Math.random() * 1000;
console.log(`Intento ${attempt} falló. Reintentando en ${Math.round(delay)}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
Resumen de Mejores Prácticas
- Lee los encabezados de rate limit — No adivines los límites; usa los valores que la API te indica.
- Implementa backoff exponencial — Nunca reintentes inmediatamente después de un error 429 o 500.
- Agrega jitter a los retrasos — El jitter aleatorio previene problemas de thundering herd cuando múltiples workers reintentan simultáneamente.
- Usa límites de concurrencia — Nunca dispares solicitudes paralelas ilimitadas.
- Monitorea el uso — Sigue tu consumo diario y mensual para evitar agotamiento de cuota.
- Falla con gracia — Almacena los elementos fallidos para reintento posterior en lugar de perderlos.
- Usa webhooks — Para grandes lotes, prefiere procesamiento asíncrono con callbacks de webhook.
TongoRender proporciona límites de tasa generosos, encabezados de respuesta claros y un endpoint de lote para casos de uso de alto volumen. Siguiendo estos patrones, tu aplicación manejará cualquier volumen de manera confiable.
Escala con TongoRender — 100 renderizaciones gratuitas al mes, sin necesidad de tarjeta de crédito.