Image de couverture : Retries et circuit breakers : pourquoi ils n'ont rien à faire dans vos cas d'usage
tech

Retries et circuit breakers : pourquoi ils n'ont rien à faire dans vos cas d'usage

15 June 2026
7 min de lecture
11 vues
Sébastien Muler

Vous ouvrez un cas d'usage PlaceOrder. Il charge le client, construit la commande, débite la carte via une passerelle de paiement, enregistre tout, publie un événement. Limpide, lisible, presque évident.

Puis, un sprint plus tard, quelqu'un ajoute une boucle de retry autour de l'appel de paiement, parce que la passerelle est capricieuse sous forte charge. Le cas d'usage hérite d'un for ($i = 0; $i < 3; $i++), d'un usleep(), et d'un commentaire du type // la passerelle plante le lundi.

La règle métier — placer une commande — est désormais noyée sous de la plomberie de résilience. Le prochain développeur qui ajoute un appel externe va copier-coller ce bloc, avec un backoff légèrement différent, jamais testé, jamais réutilisable.

C'est exactement le constat que dresse Gabriel Anhaia dans son article "Retries and Circuit Breakers Belong in the Adapter, Not Your Use Case" (dev.to). Et c'est un sujet qu'on retrouve très souvent dans les bases de code PHP/Symfony et Laravel : la frontière entre « ce que fait l'application » et « comment elle survit aux pannes du monde extérieur » devient floue, au détriment de la lisibilité... et de la fiabilité.

La panne transitoire n'est pas une règle métier

Un cas d'usage répond à une seule question : que fait l'application quand tel événement survient ? Placer une commande. Annuler un abonnement. Émettre un remboursement. Ce sont des décisions que le métier comprend et valide.

En revanche, « la passerelle de paiement a répondu 503 et il faut retenter trois fois avec un backoff exponentiel avant d'abandonner » n'est pas une règle métier. C'est une caractéristique du réseau, du protocole HTTP, de l'infrastructure de votre fournisseur de paiement. Le métier ne sait même pas que cette information existe — et il n'a pas à le savoir.

Pourtant, c'est exactement ce qui se produit lorsque la logique de retry et de circuit breaker s'infiltre dans le cas d'usage : on mélange deux préoccupations qui n'ont aucune raison d'habiter le même fichier.

Le symptôme est facile à reconnaître : un test unitaire de cas d'usage qui doit simuler des timeouts, des exceptions réseau, ou attendre artificiellement plusieurs secondes pour vérifier le comportement de retry. Si vos tests métier dépendent du comportement du réseau, c'est le signal que la résilience a migré au mauvais endroit.

L'adaptateur : le bon endroit pour absorber l'instabilité

Dans une architecture hexagonale (ports & adapters), le cas d'usage dépend d'un port — une interface qui décrit ce dont le métier a besoin, sans aucune trace de HTTP, de timeouts ou de retries. C'est l'adaptateur, qui implémente ce port et parle réellement au monde extérieur, qui doit absorber l'instabilité.

L'adaptateur sait que le réseau est faillible. Il sait que la passerelle de paiement répond parfois mal le lundi. C'est donc lui — et lui seul — qui doit porter :

  • la politique de retry (nombre de tentatives, backoff, jitter) ;
  • le circuit breaker (ouverture du circuit après N échecs consécutifs, période de repos avant nouvelle tentative) ;
  • les timeouts spécifiques au service appelé ;
  • la traduction des erreurs techniques (exceptions HTTP, codes 5xx) en erreurs métier compréhensibles par le cas d'usage, comme PaymentGatewayUnavailableException.

Le cas d'usage, lui, ne voit qu'une interface simple : « débite ce montant » ou « ça a échoué ». Il n'a aucune idée de la mécanique sous-jacente.

Exemple concret en PHP/Symfony

Le port reste minimaliste, exprimé dans le langage du métier :

interface PaymentGatewayInterface
{
    public function charge(Order $order, Money $amount): PaymentResult;
}

L'adaptateur, lui, porte toute la complexité technique :

final class StripePaymentGatewayAdapter implements PaymentGatewayInterface
{
    public function __construct(
        private HttpClientInterface $client,
        private CircuitBreaker $circuitBreaker,
    ) {}

    public function charge(Order $order, Money $amount): PaymentResult
    {
        if (!$this->circuitBreaker->isClosed('stripe')) {
            throw new PaymentGatewayUnavailableException('stripe');
        }

        try {
            $response = $this->client->request('POST', '/v1/charges', [
                'json' => [
                    'order_id' => $order->id(),
                    'amount' => $amount->amount(),
                    'currency' => $amount->currency(),
                ],
            ]);

            $this->circuitBreaker->recordSuccess('stripe');

            return PaymentResult::fromResponse($response);
        } catch (TransportExceptionInterface $e) {
            $this->circuitBreaker->recordFailure('stripe');

            throw new PaymentGatewayUnavailableException('stripe', previous: $e);
        }
    }
}

La gestion des retries peut s'appuyer sur des briques existantes plutôt que sur une boucle artisanale. Symfony fournit par exemple RetryableHttpClient, qui décore n'importe quel HttpClientInterface avec une stratégie configurable (nombre de tentatives, délai initial, multiplicateur de backoff, jitter) :

$client = new RetryableHttpClient(
    HttpClient::create(),
    new GenericRetryStrategy(
        statusCodes: [0, 423, 425, 429, 500, 502, 503, 504],
        delayMs: 200,
        multiplier: 2.0,
        maxDelayMs: 5000,
        jitter: 0.2,
    ),
    maxRetries: 3,
);

Cette configuration vit dans la couche infrastructure, au même endroit que le client HTTP lui-même. Le circuit breaker peut s'implémenter avec un store rapide (Redis, par exemple) pour partager l'état entre les workers, et des librairies dédiées existent déjà pour PHP si vous ne souhaitez pas le réimplémenter.

Enfin, le cas d'usage reste exactement ce qu'il doit être : une description du métier, sans aucune mention de retry, de timeout ou de circuit breaker.

final class PlaceOrder
{
    public function __construct(
        private CustomerRepositoryInterface $customers,
        private PaymentGatewayInterface $paymentGateway,
        private OrderRepositoryInterface $orders,
        private EventDispatcherInterface $events,
    ) {}

    public function __invoke(PlaceOrderCommand $command): Order
    {
        $customer = $this->customers->get($command->customerId);
        $order = Order::place($customer, $command->items);

        $this->paymentGateway->charge($order, $order->total());

        $this->orders->save($order);
        $this->events->dispatch(new OrderPlaced($order));

        return $order;
    }
}

Si la passerelle est indisponible, PaymentGatewayUnavailableException remonte naturellement, et c'est au cas d'usage — ou à la couche application — de décider ce que cela signifie pour le métier : annuler la commande, la mettre en attente, notifier le client. Cette décision, elle, est bien une règle métier.

Les bénéfices, au-delà de l'élégance

Ce découpage n'est pas qu'une question de style. Il change concrètement la façon dont une équipe travaille :

  • Lisibilité : un cas d'usage se lit en une minute, sans avoir à filtrer du code de plomberie réseau.
  • Testabilité : les tests métier mockent simplement le port (PaymentGatewayInterface) avec des réponses immédiates, sans simuler de latences ni d'exceptions HTTP.
  • Cohérence : la politique de retry et de circuit breaker est définie une seule fois, dans la couche infrastructure, et s'applique de façon uniforme à tous les appels sortants — paiement, e-mail, webhook, API tierce.
  • Évolutivité : ajuster le nombre de tentatives ou le seuil d'ouverture du circuit breaker se fait dans l'adaptateur, sans toucher au moindre fichier métier ni redéployer la logique de commande.

Conclusion 🎯

La frontière entre « ce que fait votre application » et « comment elle résiste aux pannes du monde extérieur » est l'un de ces choix d'architecture qui semblent invisibles au quotidien... jusqu'au jour où une passerelle tombe en rade un lundi matin et que personne ne sait exactement ce qui va se passer.

Garder les retries et circuit breakers dans l'adaptateur, et les règles métier dans le cas d'usage, n'est pas un exercice de pureté architecturale : c'est ce qui permet à votre application de continuer à fonctionner — ou d'échouer proprement — quand l'infrastructure autour d'elle vacille.

Cet article s'inspire de la publication originale de Gabriel Anhaia, "Retries and Circuit Breakers Belong in the Adapter, Not Your Use Case", disponible sur dev.to. Chez MulerTech, c'est précisément ce type de séparation que nous appliquons dans nos architectures hexagonales PHP/Symfony, pour livrer des applications qui survivent à leurs dépendances.

Partager cet article