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 randomimport
timeimport
httpxdef 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 intento == max_retries - 1:
raise
# Retraso exponencial: 1 s, 2 s, 4 s
retardo_base = 2 ** intento
# Variación: aleatorización para evitar el efecto «manada»
variación = random.uniform(0, retardo_base)
delay = base_delay + jitter
print(f"Reintento {attempt + 1}/{max_retries} en {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 timefrom
enum import
Enumclass CircuitState(Enum):
CLOSED = 'closed' # funcionamiento
normal OPEN = 'open' # fallo, rechazar todas las solicitudes
HALF_OPEN = 'half_open' # probando la
recuperaciónclass CircuitBreaker:
def __init__(self, umbral_de_fallo=5, tiempo_de_espera_de_recuperación=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:
si time.time() - self.last_failure_time > self.recovery_timeout:
self.state = CircuitState.HALF_OPEN
else:
raise CircuitBreakerOpen("Servicio no disponible")
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# Uso
payment_breaker = CircuitBreaker(failure_threshold=5)
try:
result = payment_breaker.call(stripe.PaymentIntent.create, amount=1000)
except CircuitBreakerOpen:
return Response(
{'error': 'Servicio de pago temporalmente no disponible'},
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:
# Middleware de Django: limitador de velocidad con ventana
deslizante respaldado por Redisimport
timeimport redisclass 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}'
# Ventana deslizante: 100 solicitudes por cada 60 segundos
now = time.time()
pipe = self.redis.pipeline()
pipe.zremrangebyscore(key, 0, now - 60) # eliminar entradas
antiguas pipe.zadd(key, {str(now): now}) # añadir la solicitud actual
pipe.zcard(key) # contar solicitudes
pipe.expire(key, 60) # limpieza automática
_, _, request_count, _ = pipe.execute()
if request_count > 100:
return JsonResponse(
{'error': 'Se ha superado el límite de rate. Inténtalo de nuevo en 60 segundos.',
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
