Limitation du débit, disjoncteurs et tempêtes de tentatives : concevoir des API résilientes
Votre API traite sans problème 1 000 requêtes par seconde. Puis, un service en aval tombe en panne, les clients recommencent leurs tentatives, et soudain, vous êtes submergé par 50 000 requêtes par seconde. Voici comment éviter les défaillances en cascade.
La vague de tentatives de reconnexion qui a paralysé la production
Voici ce qui s'est passé sur un système réel : une réplique de la base de données a été indisponible pendant 2 minutes. L'API a commencé à renvoyer des erreurs 500. Chaque client disposait d'une logique de réessai : 3 tentatives sans délai d'attente. 1 000 clients × 3 tentatives = 3 000 requêtes par seconde s'abattant sur un serveur déjà à bout de souffle. La base de données principale n'a pas pu supporter ce pic de trafic. Tout était alors hors service.
C'est ce qu'on appelle une « tempête de tentatives de reconnexion », et c'est la principale cause des défaillances en cascade dans les systèmes distribués.
Solution 1 : Recul exponentiel avec gigue
Ne relancez jamais immédiatement. Utilisez un délai d'attente exponentiel (en augmentant le temps d'attente à chaque nouvelle tentative) avec un décalage aléatoire (pour éviter que tous les clients ne relancent leur tentative au même moment) :
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
# Retard exponentiel : 1 s, 2 s, 4 s
base_delay = 2 ** attempt
# Jitter : randomisation pour éviter l'effet « thundering herd »
jitter = random.uniform(0, base_delay)
delay = base_delay + jitter
print(f"Nouvelle tentative {attempt + 1}/{max_retries} en {delay:.1f}s")
time.sleep(delay)Solution 2 : Schéma des disjoncteurs
Un disjoncteur de circuit détecte les défaillances. Une fois un seuil atteint (par exemple, 5 défaillances en 30 secondes), il se « déclenche » et renvoie immédiatement des erreurs sans appeler le service en aval. Après une période de pause, il laisse passer une requête afin de vérifier si le service s'est rétabli.
import time
from enum import Enum
class CircuitState(Enum) :
CLOSED = 'closed' # fonctionnement
normal OPEN = 'open' # en échec, rejeter toutes les requêtes
HALF_OPEN = 'half_open' # test de
récupération 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 indisponible")
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#
Utilisationpayment_breaker = CircuitBreaker(failure_threshold=5)
try:
result = payment_breaker.call(stripe.PaymentIntent.create, amount=1000)
except CircuitBreakerOpen:
return Response(
{'error': 'Service de paiement temporairement indisponible'},
status=503
)Solution 3 : Limitation du débit côté serveur
Protégez votre API contre la surutilisation légitime et les abus malveillants. Utilisez un limiteur de débit à fenêtre glissante s'appuyant sur Redis :
# Middleware Django : limiteur de débit à fenêtre
glissante basé sur 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}'
# Fenêtre glissante : 100 requêtes par 60 secondes
now = time.time()
pipe = self.redis.pipeline()
pipe.zremrangebyscore(key, 0, now - 60) # supprimer les anciennes entrées
pipe.zadd(key, {str(now): now}) # ajouter la requête
actuelle pipe.zcard(key) # compter les requêtes
pipe.expire(key, 60) # nettoyage automatique
_, _, request_count, _ = pipe.execute()
if request_count > 100:
return JsonResponse(
{'error': 'Limite de débit dépassée. Réessayez dans 60 secondes.',
status=429,
headers={'Retry-After': '60'}
)
return self.get_response(request)La liste de contrôle de la résilience
Côté client : délai d'attente exponentiel + variation aléatoire pour toutes les tentatives de reconnexion. Définir des délais d'expiration pour les requêtes (ne jamais attendre indéfiniment). Mettre en place des disjoncteurs pour chaque dépendance externe.
Côté serveur : limitation du débit par adresse IP et par clé API. Dégradation progressive (renvoi de données mises en cache lorsque la base de données est lente). Points de contrôle de l'état de santé pour les équilibreurs de charge. Modèle « bulkhead » (isolation des services critiques par rapport aux services non critiques).
Infrastructure : mise à l'échelle automatique en fonction de la longueur de la file d'attente des requêtes, et pas uniquement de l'utilisation du processeur. Répliques de lecture distinctes pour les points de terminaison à forte activité de lecture. CDN pour les ressources statiques et les réponses d'API pouvant être mises en cache.
Tout tombe en panne, tout le temps. La question n'est pas de savoir si vos dépendances vont tomber en panne, mais si votre système est capable de gérer cette situation avec élégance lorsque cela se produit.
— Werner Vogels, directeur technique d'Amazon
