Limitación de velocidad, interruptores de circuito y tormentas de reintentos: cómo crear API resilientes
Tu API gestiona sin problemas 1.000 solicitudes por segundo. Entonces, un servicio posterior deja de funcionar, los clientes empiezan a reintentar la conexión y, de repente, te ves desbordado por 50.000 solicitudes por segundo. A continuación te explicamos cómo evitar los fallos en cadena.
La avalancha de reintentos que colapsó la producción
Esto es lo que ocurrió en un sistema real: una réplica de la base de datos estuvo inactiva durante 2 minutos. La API empezó a devolver errores 500. Todos los clientes tenían una lógica de reintento: reintentar 3 veces sin intervalo de espera. 1.000 clientes × 3 reintentos = 3.000 solicitudes por segundo que llegaban a un servidor que ya estaba saturado. La base de datos principal no pudo soportar ese pico de tráfico. Entonces, todo dejó de funcionar.
Esto se conoce como «tormenta de reintentos» y es la principal causa de fallos en cadena en los sistemas distribuidos.
Solución 1: Retraso exponencial con fluctuación
Nunca vuelvas a intentarlo inmediatamente. Utiliza un retraso exponencial (espera más tiempo en cada intento) con una variación aleatoria (para evitar que todos los clientes vuelvan a intentarlo al mismo tiempo):
import random
import time
import httpx
def fetch_with_retry(url, max_retries=3):
for attempt in range(max_retries):
try:
response = httpx.get(url, timeout=5.0)
response.raise_for_status()
return response.json()
except (httpx.HTTPStatusError, httpx.TimeoutException) as e:
if attempt == max_retries - 1:
raise
# Exponential backoff: 1s, 2s, 4s
base_delay = 2 ** attempt
# Jitter: randomize to prevent thundering herd
jitter = random.uniform(0, base_delay)
delay = base_delay + jitter
print(f"Retry {attempt + 1}/{max_retries} in {delay:.1f}s")
time.sleep(delay)Solución 2: Patrón de disyuntor
Un disyuntor registra los fallos. Una vez superado un umbral (por ejemplo, 5 fallos en 30 segundos), se «abre» y devuelve inmediatamente errores sin llamar al servicio posterior. Tras un periodo de espera, deja pasar una solicitud para comprobar si el servicio se ha recuperado.
import time
from enum import Enum
class CircuitState(Enum):
CLOSED = 'closed' # normal operation
OPEN = 'open' # failing, reject all requests
HALF_OPEN = 'half_open' # testing recovery
class CircuitBreaker:
def __init__(self, failure_threshold=5, recovery_timeout=30):
self.state = CircuitState.CLOSED
self.failure_count = 0
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.last_failure_time = None
def call(self, func, *args, **kwargs):
if self.state == CircuitState.OPEN:
if time.time() - self.last_failure_time > self.recovery_timeout:
self.state = CircuitState.HALF_OPEN
else:
raise CircuitBreakerOpen("Service unavailable")
try:
result = func(*args, **kwargs)
self._on_success()
return result
except Exception as e:
self._on_failure()
raise
def _on_success(self):
self.failure_count = 0
self.state = CircuitState.CLOSED
def _on_failure(self):
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = CircuitState.OPEN
# Usage
payment_breaker = CircuitBreaker(failure_threshold=5)
try:
result = payment_breaker.call(stripe.PaymentIntent.create, amount=1000)
except CircuitBreakerOpen:
return Response(
{'error': 'Payment service temporarily unavailable'},
status=503
)Solución 3: Limitación de velocidad del lado del servidor
Protege tu API tanto del uso excesivo legítimo como del uso malintencionado. Utiliza un limitador de tasa con ventana móvil respaldado por Redis:
# Django middleware: Redis-backed sliding window rate limiter
import time
import redis
class RateLimitMiddleware:
def __init__(self, get_response):
self.get_response = get_response
self.redis = redis.Redis(host='redis', port=6379)
def __call__(self, request):
client_ip = request.META.get('HTTP_X_FORWARDED_FOR',
request.META['REMOTE_ADDR'])
key = f'rate_limit:{client_ip}'
# Sliding window: 100 requests per 60 seconds
now = time.time()
pipe = self.redis.pipeline()
pipe.zremrangebyscore(key, 0, now - 60) # remove old entries
pipe.zadd(key, {str(now): now}) # add current request
pipe.zcard(key) # count requests
pipe.expire(key, 60) # auto-cleanup
_, _, request_count, _ = pipe.execute()
if request_count > 100:
return JsonResponse(
{'error': 'Rate limit exceeded. Try again in 60s.'},
status=429,
headers={'Retry-After': '60'}
)
return self.get_response(request)La lista de verificación de la resiliencia
Del lado del cliente: retroceso exponencial + fluctuación en todos los reintentos. Establecer tiempos de espera para las solicitudes (no esperar eternamente). Implementar interruptores de circuito para cada dependencia externa.
Del lado del servidor: limitación de velocidad por IP y por clave API. Degradación gradual (devolución de datos almacenados en caché cuando la base de datos funciona con lentitud). Puntos finales de comprobación de estado para equilibradores de carga. Patrón «bulkhead» (aislamiento de los servicios críticos respecto a los no críticos).
Infraestructura: Escalado automático basado en la profundidad de la cola de solicitudes, no solo en la CPU. Réplicas de lectura independientes para puntos finales con un alto volumen de lecturas. CDN para recursos estáticos y respuestas de API almacenables en caché.
Todo falla, constantemente. La cuestión no es si tus dependencias dejarán de funcionar, sino si tu sistema sabrá gestionarlo con elegancia cuando eso ocurra.
— Werner Vogels, director tecnológico de Amazon
