Et si la panne de votre prestataire de paiement faisait tomber toute votre plateforme ?
C'est un scénario que tout développeur backend redoute : une API tierce — passerelle de paiement, service SMS, calculateur de frais de livraison — devient soudainement indisponible ou extrêmement lente. En quelques minutes, les threads de votre serveur s'accumulent en attendant des timeouts réseau qui n'arrivent jamais. Le pool de connexions s'épuise. Et c'est toute votre application qui s'effondre, à cause d'un service externe qui n'a rien à voir avec votre cœur de métier.
La solution à ce problème s'appelle le Circuit Breaker Pattern. Cet article vous explique comment l'implémenter de manière atomique et distribuée avec Redis, en s'inspirant d'un retour d'expérience publié sur dev.to par Aasim Ghaffar, adapté ici aux bonnes pratiques de l'écosystème PHP.
Le problème : les défaillances en cascade
Dans une architecture moderne, votre application PHP dépend d'une constellation de services externes. Cette dépendance est une force — elle vous permet de déléguer des fonctionnalités complexes — mais aussi une vulnérabilité structurelle.
Lorsqu'une API externe répond lentement (ou plus du tout), votre application continue d'envoyer des requêtes. Chaque requête en attente mobilise un thread ou un worker. Très vite :
- Le pool de workers PHP-FPM est saturé
- Les nouvelles requêtes entrantes s'accumulent en file d'attente
- Les timeouts s'enchaînent, générant des erreurs 502 ou 504
- L'ensemble de la plateforme devient indisponible
C'est ce qu'on appelle une défaillance en cascade (cascading failure). Le problème n'est pas l'API tierce elle-même — c'est que votre système n'est pas conçu pour absorber sa défaillance.
Le Circuit Breaker : échouer vite pour mieux survivre
Le pattern Circuit Breaker s'inspire directement des disjoncteurs électriques. Il agit comme un proxy intelligent devant chaque appel externe et fonctionne selon trois états :
- Fermé (Closed) : tout fonctionne normalement, les requêtes passent.
- Ouvert (Open) : le seuil d'erreurs a été atteint, les requêtes sont bloquées immédiatement et une réponse de fallback est retournée sans solliciter l'API.
- Semi-ouvert (Half-Open) : après une période de cooldown, quelques requêtes sont laissées passer pour tester si le service est rétabli.
L'objectif est de fail fast : échouer immédiatement et proprement, plutôt que d'attendre un timeout de 30 secondes multiplié par des centaines de requêtes simultanées.
Pourquoi Redis ?
Dans un environnement distribué (plusieurs serveurs, plusieurs workers), l'état du circuit breaker doit être partagé et cohérent. Stocker cet état en mémoire locale par processus PHP ne fonctionnerait pas : chaque worker aurait sa propre vision de l'état du circuit.
Redis est la solution naturelle ici pour plusieurs raisons :
- Atomicité : les opérations comme
INCRouSET NXsont atomiques, ce qui évite les race conditions entre workers concurrents. - TTL natif : on peut faire expirer automatiquement les clés (par exemple, réinitialiser le compteur d'erreurs après 60 secondes).
- Performances : les lectures/écritures Redis sont en mémoire, leur latence est négligeable par rapport à un appel API externe.
Implémentation : un Circuit Breaker atomique en PHP
Voici une implémentation en PHP, applicable aussi bien dans un contexte Symfony (via un service injectable) que Laravel. La logique est identique dans les deux frameworks.
namespace App\Services;
use Redis;
use Exception;
class CircuitBreaker
{
private const STATE_CLOSED = 'closed';
private const STATE_OPEN = 'open';
private const KEY_STATE = 'circuit:external_api:state';
private const KEY_FAILURES = 'circuit:external_api:failures';
private const FAILURE_THRESHOLD = 5; // Nombre d'échecs avant ouverture
private const COOLDOWN_TTL = 60; // Secondes avant tentative de réinitialisation
private const FAILURE_WINDOW = 120; // Fenêtre de comptage des échecs (en secondes)
public function __construct(private Redis $redis) {}
public function call(callable $apiCall): mixed
{
if ($this->isOpen()) {
// Fail fast : on n'appelle même pas l'API
return $this->fallback();
}
try {
$result = $apiCall();
$this->onSuccess();
return $result;
} catch (Exception $e) {
$this->onFailure();
return $this->fallback();
}
}
private function isOpen(): bool
{
return $this->redis->get(self::KEY_STATE) === self::STATE_OPEN;
}
private function onSuccess(): void
{
// Réinitialisation complète en cas de succès
$this->redis->del(self::KEY_STATE);
$this->redis->del(self::KEY_FAILURES);
}
private function onFailure(): void
{
// INCR est atomique : pas de race condition possible
$failures = $this->redis->incr(self::KEY_FAILURES);
if ($failures === 1) {
// Première erreur : on démarre la fenêtre de comptage
$this->redis->expire(self::KEY_FAILURES, self::FAILURE_WINDOW);
}
if ($failures >= self::FAILURE_THRESHOLD) {
// Seuil atteint : on ouvre le circuit
$this->redis->setex(self::KEY_STATE, self::COOLDOWN_TTL, self::STATE_OPEN);
}
}
private function fallback(): array
{
return [
'status' => 'unavailable',
'message' => 'Service temporairement indisponible. Veuillez réessayer dans quelques instants.',
];
}
}
Points clés de cette implémentation
INCRatomique : Redis garantit qu'un seul worker à la fois incrémente le compteur, éliminant les conditions de course.- TTL sur l'état ouvert : le circuit se referme automatiquement après
COOLDOWN_TTLsecondes, sans intervention manuelle. - Fenêtre glissante : le compteur d'échecs expire après
FAILURE_WINDOWsecondes, évitant qu'une ancienne série d'erreurs maintienne le circuit ouvert indéfiniment. - Fallback explicite : l'appelant reçoit toujours une réponse structurée, jamais une exception non gérée.
Utilisation dans un contrôleur Symfony
// Dans un service ou un contrôleur
public function processPayment(CircuitBreaker $circuitBreaker): JsonResponse
{
$result = $circuitBreaker->call(function () {
return $this->paymentGateway->charge(...);
});
if ($result['status'] === 'unavailable') {
// Loguer, notifier, mettre en file d'attente...
}
return new JsonResponse($result);
}
Ce qu'il faut surveiller en production
Un Circuit Breaker n'est efficace que si vous l'observez. Quelques recommandations pour le déploiement :
- Métriques : exposez des compteurs Prometheus (ou équivalent) sur l'état du circuit, le nombre d'ouvertures et la durée moyenne en état ouvert.
- Alerting : une ouverture de circuit doit déclencher une alerte immédiate — c'est le signe qu'un service externe est en difficulté.
- Logs structurés : chaque transition d'état (ouverture, fermeture, fallback déclenché) doit être loguée avec contexte pour faciliter le diagnostic.
- Tests de chaos : simulez régulièrement des pannes d'APIs tierces en environnement de staging pour valider le comportement du circuit breaker sous charge réelle.
Conclusion
Le Circuit Breaker Pattern est l'un des piliers de la résilience applicative. Il ne s'agit pas d'une optimisation optionnelle, mais d'une nécessité dès lors que votre application dépend de services externes en production.
L'implémentation présentée ici est volontairement simple et directement adaptable à n'importe quel projet PHP/Symfony existant. Elle repose sur les primitives atomiques de Redis pour garantir un comportement cohérent dans un environnement distribué, sans bibliothèque externe supplémentaire.
La prochaine fois qu'une API tierce tombera — et elle tombera — votre application répondra toujours correctement à vos utilisateurs. C'est exactement ce qu'on attend d'un système de production robuste.
💡 Cet article s'inspire du retour d'expérience publié par Aasim Ghaffar sur dev.to, adapté et enrichi pour l'écosystème PHP/Symfony.