Developer tools: cómo gestionar rate limits en APIs de datos sin bloquear el pipeline
Developer tools: cómo gestionar rate limits en APIs de datos sin bloquear el pipeline
El equipo ha construido el pipeline. Las llamadas funcionan en staging. Llega el lunes de producción con carga real, y a las dos horas el sistema empieza a devolver 429 Too Many Requests. La ingesta se detiene. Los datos dejan de fluir. El equipo que consume esos datos al otro lado del pipeline no sabe por qué.
El rate limiting es uno de los problemas más previsibles del ecosistema de APIs de datos, y también uno de los más mal gestionados. No porque los developers no sepan que existe, sino porque en la mayoría de los proyectos se aborda tarde: cuando el problema ya está en producción, no durante el diseño.
Este post recorre los patrones técnicos que realmente funcionan cuando el volumen de señales que procesas es alto y el coste por llamada fallida no es cero.
Por qué el rate limiting rompe pipelines que "funcionaban bien"
El error clásico es dimensionar el pipeline contra el throughput medio, no contra el throughput pico. En staging, el volumen es controlado. En producción, hay picos: campañas de comunicación, eventos de mercado, crisis reputacionales que disparan el volumen de menciones en pocas horas.
Cuando el pipeline no tiene mecanismos de absorción de picos, las llamadas se acumulan, el proveedor devuelve 429, el sistema reintenta de forma inmediata (agravando el problema) y el pipeline queda bloqueado o, peor, empieza a descartar señales silenciosamente.
El segundo factor es la granularidad del límite. Muchas APIs de datos aplican límites por segundo, por minuto y por día simultáneamente. Un sistema que respeta el límite por minuto puede violar el límite por segundo si ráfagas cortas de llamadas no están distribuidas. El resultado es el mismo: 429, con un sistema que técnicamente "no ha superado la cuota".
Patrón 1: cola con presupuesto de llamadas explícito
El primer cambio estructural es separar la generación de solicitudes de la ejecución de llamadas API. En lugar de que cada proceso llame directamente al endpoint, las solicitudes van a una cola interna. Un componente dedicado —el dispatcher— consume la cola a la velocidad que el contrato API permite.
import time
import queue
import threading
class RateLimitedDispatcher:
def __init__(self, calls_per_second: float, api_client):
self.interval = 1.0 / calls_per_second
self.queue = queue.Queue()
self.client = api_client
self._start_worker()
def _start_worker(self):
def worker():
while True:
request = self.queue.get()
if request is None:
break
self.client.execute(request)
time.sleep(self.interval)
threading.Thread(target=worker, daemon=True).start()
def enqueue(self, request):
self.queue.put(request)
Este patrón tiene una ventaja inmediata: el resto del sistema no necesita conocer los límites de la API. La lógica de control está centralizada. Cambiar el throughput permitido es un ajuste en un único punto.
Patrón 2: backoff exponencial con jitter real
El reintento inmediato tras un 429 es el error más común. Agrava exactamente el problema que lo provocó. El backoff exponencial es conocido; el jitter no siempre se implementa bien.
El jitter añade aleatoriedad al tiempo de espera para evitar que múltiples instancias del sistema reintenten de forma sincronizada —lo que recrea el pico original.
import random
import time
def backoff_with_jitter(attempt: int, base: float = 1.0, cap: float = 60.0) -> float:
"""Calcula el tiempo de espera con exponential backoff + full jitter."""
exponential = min(cap, base * (2 ** attempt))
return random.uniform(0, exponential)
def call_with_retry(api_func, max_attempts: int = 5):
for attempt in range(max_attempts):
try:
return api_func()
except RateLimitError:
if attempt == max_attempts - 1:
raise
wait = backoff_with_jitter(attempt)
time.sleep(wait)
El parámetro cap es importante: evita tiempos de espera absurdos en cascadas de errores. En APIs de datos con ventanas de límite por minuto, un cap de 60-90 segundos suele ser suficiente para esperar el reset de la ventana.
Patrón 3: circuit breaker para proteger el pipeline downstream
Cuando una API de datos falla de forma sostenida, el pipeline no debe seguir acumulando reintentos indefinidamente. Un circuit breaker detiene las llamadas durante un período de enfriamiento y las retoma de forma controlada.
import time
from enum import Enum
class State(Enum):
CLOSED = "closed" # Funcionando con normalidad
OPEN = "open" # Bloqueando llamadas
HALF_OPEN = "half_open" # Probando recuperación
class CircuitBreaker:
def __init__(self, failure_threshold=5, recovery_timeout=30):
self.threshold = failure_threshold
self.timeout = recovery_timeout
self.failures = 0
self.state = State.CLOSED
self.opened_at = None
def call(self, func):
if self.state == State.OPEN:
if time.time() - self.opened_at > self.timeout:
self.state = State.HALF_OPEN
else:
raise CircuitOpenError("Circuit abierto, llamada bloqueada")
try:
result = func()
self._on_success()
return result
except Exception as e:
self._on_failure()
raise
def _on_success(self):
self.failures = 0
self.state = State.CLOSED
def _on_failure(self):
self.failures += 1
if self.failures >= self.threshold:
self.state = State.OPEN
self.opened_at = time.time()
El circuit breaker protege dos cosas: la API externa (evita seguir golpeándola cuando está bajo presión) y el pipeline interno (evita que los errores se propaguen hacia consumidores downstream que no tienen contexto del problema).
Monitorizar el presupuesto de llamadas antes de que se agote
Los tres patrones anteriores son reactivos: responden cuando el límite ya se ha alcanzado. La capa proactiva es monitorizar el consumo de cuota en tiempo real y ajustar el throughput antes de llegar al límite.
Las APIs bien diseñadas exponen la cuota restante en las cabeceras de respuesta: X-RateLimit-Remaining, X-RateLimit-Reset. No todas las APIs las incluyen, pero cuando están disponibles, leerlas es obligatorio.
def extract_rate_limit_headers(response_headers: dict) -> dict:
return {
"remaining": int(response_headers.get("X-RateLimit-Remaining", -1)),
"reset_at": int(response_headers.get("X-RateLimit-Reset", 0)),
"limit": int(response_headers.get("X-RateLimit-Limit", -1)),
}
Con estos valores en mano, el dispatcher puede reducir su velocidad de forma anticipada cuando el porcentaje de cuota consumida supera un umbral (por ejemplo, 80%), sin esperar al 429.
En plataformas con modelo pay-as-you-go como FeedScale, este control tiene además un impacto directo en el coste: una llamada fallida que se reintenta tres veces puede contabilizar según el contrato. Gestionar el presupuesto de llamadas no es solo resiliencia técnica, es control de gasto.
Lo que el logging debe capturar siempre
Ninguno de estos patrones funciona si el sistema no genera trazabilidad suficiente. Cada llamada a la API debería registrar al menos:
- Timestamp de la llamada y de la respuesta
- Código HTTP devuelto
- Cuota restante en el momento de la llamada (si está disponible en cabeceras)
- Intento número (si es un reintento)
- Tiempo de espera aplicado (si hubo backoff)
Sin estos datos, depurar un pico de 429 a posteriori es casi imposible. El log solo dirá que hubo errores, no cuándo empezaron a acumularse ni qué componente los desencadenó.
El rate limiting como dato de diseño, no como caso de error
La diferencia entre un pipeline frágil y uno robusto no está en el código de negocio. Está en cómo se diseñó la relación con las APIs externas. El rate limiting no es un caso de error excepcional: es una restricción operativa conocida desde el primer día.
Tratarlo como tal —con presupuesto explícito, backoff con jitter, circuit breakers y monitorización de cabeceras— convierte un punto de fallo frecuente en un comportamiento predecible y gestionable. Eso es lo que distingue una integración de producción real de una prueba de concepto que funciona en staging.