---
title: 'Intégrations résilientes : timeouts, Retry, Circuit Breaker et idempotence | DevSense'
description: "Comment s'intégrer aux prestataires de paiement et aux API de fournisseurs sans double débit ni pannes en cascade : timeouts, nouvelles tentatives avec backoff, Circuit Breaker, clés d'idempotence et rapprochement dans Laravel."
faq:
    - { question: "Pourquoi le timeout est-il l'issue la plus dangereuse d'un appel à une API externe ?", answer: "En cas d'erreur, vous savez que l'opération n'a pas été effectuée ; en cas de succès, qu'elle l'a été. En cas de timeout, vous ne savez rien : le fournisseur a pu débiter l'argent sans avoir le temps de répondre. Une nouvelle tentative à l'aveugle mène alors à un double débit ; c'est pourquoi les opérations financières ne sont relancées qu'avec une clé d'idempotence ou après vérification du statut auprès du fournisseur." }
    - { question: 'Quelles erreurs peut-on relancer sans risque ?', answer: "Les erreurs réseau de connexion, les réponses 429 et 5xx, à condition que l'opération soit idempotente ou protégée par une clé d'idempotence. Relancer les réponses 4xx (sauf 408 et 429) n'a aucun sens : la requête est incorrecte et sera de nouveau rejetée. Les nouvelles tentatives se font avec un délai exponentiel et une dispersion aléatoire (jitter), pour ne pas déclencher une tempête de requêtes chez le fournisseur." }
    - { question: 'En quoi le Circuit Breaker diffère-t-il du Retry ?', answer: "Le Retry tente de surmonter une défaillance passagère d'une requête isolée. Le Circuit Breaker protège votre système d'une panne prolongée du fournisseur : après une série d'erreurs, il cesse d'envoyer des requêtes et renvoie immédiatement une erreur ou une solution de repli, sans immobiliser les workers dans l'attente. Après une pause, il laisse passer une requête d'essai et, si elle réussit, rouvre le trafic." }
    - { question: "Comment garantir que l'argent ne sera pas débité deux fois ?", answer: "On ne peut pas obtenir de garantie « exactement une fois » pour la livraison des messages, mais on peut obtenir exactement un effet. Pour cela, chaque opération reçoit une clé unique, enregistrée en base avec une contrainte UNIQUE avant l'appel au fournisseur et transmise à celui-ci dans l'en-tête Idempotency-Key. Une requête répétée ou un webhook reçu une seconde fois avec la même clé renvoie le résultat déjà enregistré au lieu de créer une nouvelle opération." }
published: '2026-09-28'
---
# Intégrations résilientes : timeouts, Retry, Circuit Breaker et idempotence

Le prestataire de paiement s'est mis à répondre en 30 secondes au lieu de 300 millisecondes. Une minute plus tard, tout le site était tombé, y compris des pages sans aucun rapport avec les paiements : tous les workers PHP-FPM étaient bloqués en attente de réponse. Quand le prestataire est revenu, on a découvert un second problème : une partie des utilisateurs avaient payé leur commande deux fois. Le code relançait la requête après un timeout, alors que la première requête était en réalité passée. Aucune ligne de code n'était « fausse ». L'appel externe avait simplement été écrit comme s'il s'agissait de l'appel d'une fonction locale.

**Voir aussi :** [Patterns de microservices : Saga, CQRS, Circuit Breaker](../microservices/microservice-patterns) · [Transactions distribuées](database-and-distributed-transactions) · [Comparatif des files de messages](message-queues-compared)

## Sommaire

* [Les trois issues d'un appel externe](#three-outcomes)
* [Timeouts : un budget, pas une valeur par défaut](#timeouts)
* [Retry : quoi, quand et comment relancer](#retry)
* [Idempotence : un seul effet au lieu de deux débits](#idempotency)
* [Webhooks entrants et débits concurrents](#webhooks)
* [Circuit Breaker : cesser d'appeler celui qui ne répond pas](#circuit-breaker)
* [Isolation : une file d'attente dédiée par fournisseur](#bulkheads)
* [Les limites de l'approche](#limitations)
* [Erreurs fréquentes](#common-mistakes)
* [Checklist](#checklist)
* [Quiz d'auto-évaluation](#self-test-quiz)

---

<a id="three-outcomes"></a>
## Les trois issues d'un appel externe

L'appel d'une méthode locale a deux issues : il renvoie un résultat ou lève une exception. Un appel réseau en a trois :

1. **Succès** : le fournisseur a effectué l'opération et vous avez reçu la réponse.
2. **Erreur** : le fournisseur a répondu que l'opération n'a pas été effectuée (ou la connexion n'a pas pu être établie).
3. **Inconnu** : timeout, coupure de connexion après l'envoi de la requête, 502 renvoyé par le load balancer. L'opération a pu être effectuée, ou non.

**Une intégration fiable, c'est du code qui traite explicitement la troisième issue : il limite le temps d'attente, ne relance que les opérations sûres, cesse d'appeler un fournisseur en panne et, grâce aux clés d'idempotence, transforme les nouvelles tentatives en un seul effet.**

Tout le reste de l'article décrit des façons de ne pas confondre « inconnu » et « erreur ».

---

<a id="timeouts"></a>
## Timeouts : un budget, pas une valeur par défaut

Par défaut, le client HTTP de Laravel attend une réponse jusqu'à 30 secondes. Voyons ce que cela signifie pour un site doté de 50 workers PHP-FPM, si le fournisseur « se fige » :

* chaque requête vers la page de paiement occupe un worker pendant 30 secondes ;
* à 2 requêtes par seconde, les 50 workers sont tous occupés au bout de 25 secondes ;
* les autres pages du site commencent à répondre 502, alors qu'elles n'ont aucun problème.

Le timeout est le budget que vous êtes prêt à accorder au fournisseur, en fonction de sa latence habituelle et du nombre de workers que vous pouvez vous permettre d'immobiliser :

```php
// app/Services/Payments/PaymentGatewayClient.php
<?php

declare(strict_types=1);

namespace App\Services\Payments;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;

final class PaymentGatewayClient
{
    private function http(): PendingRequest
    {
        return Http::baseUrl(config('services.gateway.url'))
            ->withToken(config('services.gateway.token'))
            ->connectTimeout(2)  // établir la connexion TCP/TLS
            ->timeout(5)         // recevoir la réponse complète
            ->acceptJson();
    }
}
```

> [!TIP]
> **Sortez les appels longs de la requête HTTP de l'utilisateur.** Si le fournisseur met plusieurs secondes à répondre, envoyez la requête depuis un job en file d'attente et affichez à l'utilisateur le statut « paiement en cours de traitement ». Le fournisseur lent occupe alors les workers de la file d'attente, et non les workers web.

---

<a id="retry"></a>
## Retry : quoi, quand et comment relancer

Une nouvelle tentative permet de surmonter une défaillance passagère : redémarrage d'un pod chez le fournisseur, micro-coupure réseau, réponse `429`. Mais le Retry est soumis à trois conditions.

**1. Ne relancer que ce qui peut l'être sans risque.** Requêtes `GET`, vérification de statut, opérations munies d'une clé d'idempotence. Un débit sans clé ne doit jamais être relancé après un timeout.

**2. Ne relancer que les erreurs récupérables.** Erreurs de connexion, `429`, `5xx`. Une réponse `422` ou `400` signifie que la requête est incorrecte, et une nouvelle tentative obtiendra la même réponse.

**3. Relancer avec une pause croissante et une dispersion aléatoire.** Si mille clients relancent leur requête exactement une seconde plus tard, le fournisseur subira un pic synchronisé au moment même où il se rétablit.

```php
// app/Services/Payments/PaymentGatewayClient.php (suite)
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Throwable;

public function paymentStatus(string $paymentId): array
{
    return $this->http()
        ->retry(
            3,
            // Pause exponentielle avec jitter : ~200, ~400, ~800 ms
            fn (int $attempt): int => (int) (100 * 2 ** $attempt + random_int(0, 100)),
            fn (Throwable $e): bool => $e instanceof ConnectionException
                || ($e instanceof RequestException
                    && ($e->response->serverError() || $e->response->status() === 429)),
        )
        ->get("/payments/{$paymentId}")
        ->throw()
        ->json();
}
```

Pour les jobs en file d'attente, la même chose se définit via les propriétés du job :

```php
// app/Jobs/CapturePayment.php
public int $tries = 5;

/** @return list<int> Pauses entre les tentatives, en secondes */
public function backoff(): array
{
    return [10, 30, 60, 300];
}
```

> [!WARNING]
> **Les nouvelles tentatives se multiplient.** Si le client HTTP fait 3 tentatives, le job en file d'attente 5, et que le service appelant relance lui aussi, le fournisseur recevra jusqu'à 15 requêtes ou plus pour une seule opération. Relancez à un seul niveau.

---

<a id="idempotency"></a>
## Idempotence : un seul effet au lieu de deux débits

Sur un réseau, on ne peut pas obtenir de garantie « exactement une fois » : soit vous risquez de perdre un message, soit de le livrer deux fois. L'objectif réaliste est une **livraison « au moins une fois » combinée à un traitement idempotent**, ce qui donne exactement un effet.

Le schéma pour un débit sortant :

1. Générer la clé de l'opération **avant** l'appel et enregistrer l'opération en base avec le statut `pending` et une contrainte UNIQUE sur la clé.
2. Transmettre la clé au fournisseur dans l'en-tête `Idempotency-Key`. Des fournisseurs comme Stripe renverront, pour une requête répétée avec la même clé, le résultat de la première au lieu d'exécuter à nouveau l'opération.
3. Enregistrer le résultat. En cas de timeout, passer le statut à `unknown` et établir la vérité via une requête de statut, et non via une nouvelle tentative de débit à l'aveugle.

```php
// database/migrations/2026_09_28_100000_create_payments_table.php
Schema::create('payments', function (Blueprint $table) {
    $table->id();
    $table->uuid('idempotency_key')->unique();
    $table->foreignId('order_id')->constrained();
    $table->unsignedBigInteger('amount');         // en unités mineures (centimes)
    $table->string('currency', 3);
    $table->string('status', 16)->index();        // pending | succeeded | failed | unknown
    $table->string('provider_payment_id')->nullable()->unique();
    $table->timestamps();
});
```

```php
// app/Services/Payments/ChargeOrder.php
<?php

declare(strict_types=1);

namespace App\Services\Payments;

use App\Models\Order;
use App\Models\Payment;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Str;

final class ChargeOrder
{
    public function __construct(private PaymentGatewayClient $gateway) {}

    public function __invoke(Order $order, string $idempotencyKey): Payment
    {
        try {
            $payment = Payment::create([
                'idempotency_key' => $idempotencyKey,
                'order_id' => $order->id,
                'amount' => $order->total,
                'currency' => $order->currency,
                'status' => 'pending',
            ]);
        } catch (UniqueConstraintViolationException) {
            // Double clic ou nouvelle tentative du job : l'opération existe déjà
            return Payment::where('idempotency_key', $idempotencyKey)->firstOrFail();
        }

        try {
            $response = $this->gateway->charge($payment); // transmet l'Idempotency-Key
            $payment->update([
                'status' => $response['status'] === 'succeeded' ? 'succeeded' : 'failed',
                'provider_payment_id' => $response['id'],
            ]);
        } catch (ConnectionException) {
            // Issue inconnue : on ne relance pas le débit, on vérifie le statut plus tard
            $payment->update(['status' => 'unknown']);
            ReconcilePayment::dispatch($payment->id)->delay(now()->addMinute());
        }

        return $payment;
    }
}
```

La clé d'idempotence est créée là où naît l'intention de l'utilisateur : par exemple, elle est générée à l'ouverture de la page de validation de commande et transmise dans un champ caché du formulaire. Ainsi, un double clic sur le bouton « Payer » se transforme lui aussi en une seule opération.

Le job `ReconcilePayment` interroge le fournisseur sur le statut via la clé ou le `provider_payment_id`, et fait passer le paiement à `succeeded` ou `failed`. Le rapprochement n'est pas une rustine, mais une partie obligatoire de toute intégration financière : lors du passage en `unknown`, seul le fournisseur connaît la vérité.

---

<a id="webhooks"></a>
## Webhooks entrants et débits concurrents

Les fournisseurs livrent les webhooks « au moins une fois » : un même événement peut arriver deux fois, simultanément sur deux nœuds, ou arriver avant votre réponse synchrone. Le handler doit être idempotent :

```php
// app/Http/Controllers/Webhooks/GatewayWebhookController.php
public function __invoke(GatewayWebhookRequest $request): Response
{
    $event = $request->validatedEvent(); // y compris la vérification de la signature

    DB::transaction(function () use ($event): void {
        // UNIQUE(provider, event_id) : le second webhook n'insérera rien
        $inserted = DB::table('processed_webhooks')->insertOrIgnore([
            'provider' => 'gateway',
            'event_id' => $event['id'],
            'created_at' => now(),
        ]);

        if ($inserted === 0) {
            return; // déjà traité
        }

        $payment = Payment::where('provider_payment_id', $event['payment_id'])
            ->lockForUpdate()
            ->firstOrFail();

        $payment->update(['status' => $event['status']]);
    });

    return response()->noContent();
}
```

Pour un solde interne (portefeuille, bonus), il faut en plus se protéger d'une race condition entre deux débits simultanés. `SELECT ... FOR UPDATE` sérialise les opérations sur une même ligne :

```php
// app/Services/Wallet/DebitWallet.php
DB::transaction(function () use ($walletId, $amount, $operationId): void {
    $wallet = Wallet::whereKey($walletId)->lockForUpdate()->firstOrFail();

    if ($wallet->balance < $amount) {
        throw new InsufficientFunds();
    }

    // UNIQUE(operation_id) dans le journal des écritures : une barrière de plus contre les doublons
    $wallet->entries()->create(['operation_id' => $operationId, 'amount' => -$amount]);
    $wallet->decrement('balance', $amount);
});
```

Pour en savoir plus sur le verrouillage pessimiste et optimiste, consultez l'article sur les [transactions distribuées](database-and-distributed-transactions).

---

<a id="circuit-breaker"></a>
## Circuit Breaker : cesser d'appeler celui qui ne répond pas

Le Retry et les timeouts sauvent une requête isolée. Mais si le fournisseur est en panne pendant dix minutes, chaque requête consomme malgré tout la totalité du budget de timeout et toutes les nouvelles tentatives. Après une série d'erreurs, le Circuit Breaker **ouvre le circuit** : les requêtes vers le fournisseur échouent immédiatement ou basculent sur une solution de repli, et une requête d'essai est autorisée toutes les N secondes.

Laravel ne fournit pas de Circuit Breaker prêt à l'emploi pour le client HTTP, mais avec les opérations atomiques du cache (Redis), il s'écrit en quelques dizaines de lignes :

```php
// app/Support/CircuitBreaker.php
<?php

declare(strict_types=1);

namespace App\Support;

use Closure;
use Illuminate\Support\Facades\Cache;
use RuntimeException;
use Throwable;

final class CircuitBreaker
{
    public function __construct(
        private string $name,
        private int $failureThreshold = 5,   // erreurs consécutives avant ouverture
        private int $openSeconds = 30,       // pause avant la requête d'essai
    ) {}

    /**
     * @template T
     * @param Closure(): T $call
     * @return T
     */
    public function call(Closure $call): mixed
    {
        if (Cache::has($this->key('open'))) {
            // On laisse passer exactement une requête d'essai après la pause (half-open)
            if (! Cache::add($this->key('probe'), true, $this->openSeconds)) {
                throw new RuntimeException("Circuit {$this->name} is open");
            }
        }

        try {
            $result = $call();
        } catch (Throwable $e) {
            $failures = Cache::increment($this->key('failures'));
            if ($failures >= $this->failureThreshold) {
                Cache::put($this->key('open'), true, $this->openSeconds * 10);
            }
            throw $e;
        }

        Cache::forget($this->key('failures'));
        Cache::forget($this->key('open'));

        return $result;
    }

    private function key(string $suffix): string
    {
        return "circuit:{$this->name}:{$suffix}";
    }
}
```

```php
// Utilisation
$status = (new CircuitBreaker('gateway'))
    ->call(fn () => $gateway->paymentStatus($paymentId));
```

Pour les jobs en file d'attente, Laravel propose déjà un équivalent proche, le middleware `ThrottlesExceptions` : après un nombre donné d'exceptions, il reporte les tentatives suivantes au lieu de marteler un service en panne :

```php
// app/Jobs/SyncGameRounds.php
use Illuminate\Queue\Middleware\ThrottlesExceptions;

public function middleware(): array
{
    // 10 exceptions → pause de 5 minutes (depuis Laravel 11, le second argument est en secondes)
    return [(new ThrottlesExceptions(10, 5 * 60))->by('provider-games')];
}
```

> [!NOTE]
> **L'état doit être partagé.** Si le compteur d'erreurs est stocké dans la mémoire du processus, chacun des 50 workers a son propre circuit, et le fournisseur recevra 50 × threshold requêtes avant que tous ne s'ouvrent. Stockez l'état dans Redis.

---

<a id="bulkheads"></a>
## Isolation : une file d'attente dédiée par fournisseur

Même avec des timeouts, un fournisseur lent peut accaparer tous les workers d'une file d'attente partagée, et les e-mails de confirmation d'inscription attendront que se libère une file saturée d'appels au prestataire de paiement. Le pattern **Bulkhead (cloisons étanches)** isole les ressources :

```php
// app/Jobs/CapturePayment.php
public function __construct(public readonly int $paymentId)
{
    $this->onQueue('payments-gateway');
}
```

```ini
; /etc/supervisor/conf.d/queue-payments.conf
[program:queue-payments-gateway]
command=php /var/www/app/current/artisan queue:work redis --queue=payments-gateway --timeout=60
numprocs=4
```

Chaque fournisseur a sa propre file d'attente et sa propre limite de processus. Quand un fournisseur ralentit, seuls ses jobs s'accumulent.

---

<a id="limitations"></a>
## Les limites de l'approche

* **Tous les fournisseurs ne prennent pas en charge les clés d'idempotence.** La seule protection est alors le rapprochement : avant toute nouvelle tentative, demander au fournisseur la liste des opérations associées à votre `order_id` ou à votre `reference`. Si une telle API n'existe pas non plus, relancer une opération financière sans vérification manuelle est inacceptable.
* **Les clés ont une durée de vie limitée.** Chez Stripe, par exemple, 24 heures. Une nouvelle tentative deux jours plus tard sera une nouvelle opération.
* **Le Circuit Breaker peut vous « protéger » d'un fournisseur en bonne santé.** Un seuil trop bas ouvre le circuit à cause de quelques erreurs fortuites. Ajustez le seuil et la fenêtre d'après les statistiques d'erreurs réelles, pas au hasard.
* **La complexité augmente.** Statuts `unknown`, jobs de rapprochement, journal des webhooks : c'est du code qu'il faut tester et superviser. Pour intégrer une API météo sur la page d'accueil, un timeout et un cache suffisent ; tout le reste est réservé à l'argent et aux commandes.

---

<a id="common-mistakes"></a>
## Erreurs fréquentes

**1. Le timeout par défaut.**
30 secondes d'attente par requête transforment une défaillance du fournisseur en panne de tout le site. Définissez `connectTimeout` et `timeout` explicitement.

**2. Relancer un débit après un timeout.**
Un timeout, c'est « inconnu », pas « erreur ». Sans clé d'idempotence, une nouvelle tentative peut débiter l'argent une seconde fois.

**3. Relancer les réponses 4xx.**
Une requête incorrecte ne deviendra pas correcte à la seconde tentative. Ne relancez que les erreurs de connexion, `429` et `5xx`.

**4. Des nouvelles tentatives à trois niveaux à la fois.**
Le client HTTP, le job et le service appelant multiplient le nombre de requêtes. Choisissez un seul niveau.

**5. Détecter les doublons via un `SELECT` plutôt qu'une contrainte UNIQUE.**
Deux requêtes parallèles verront toutes les deux « aucun enregistrement » et exécuteront toutes les deux l'opération. Seul un index unique en base protège réellement.

**6. Un Circuit Breaker en mémoire du processus.**
Chaque worker ouvre le circuit de son côté. Stockez l'état dans un stockage partagé.

**7. Pas de rapprochement.**
Les paiements au statut `unknown` restent en suspens indéfiniment, et les utilisateurs écrivent au support. Un rapprochement planifié est obligatoire.

---

<a id="checklist"></a>
## Checklist

1. Chaque appel externe a des `connectTimeout` et `timeout` explicites, cohérents avec le nombre de workers.
2. Les appels longs sont sortis de la requête HTTP de l'utilisateur et placés en file d'attente.
3. Seules les opérations idempotentes sont relancées, et uniquement sur erreurs de connexion, `429` et `5xx`, avec une pause exponentielle et du jitter.
4. Les opérations financières reçoivent une clé d'idempotence avant l'appel ; la clé est stockée avec une contrainte UNIQUE et transmise au fournisseur.
5. Un timeout fait passer l'opération en `unknown`, puis déclenche un rapprochement.
6. Les webhooks sont traités de manière idempotente : journal des événements avec UNIQUE `(provider, event_id)`.
7. Les débits sur un solde interne s'effectuent sous `lockForUpdate()`.
8. Le Circuit Breaker stocke son état dans Redis ; pour les jobs, `ThrottlesExceptions` est utilisé.
9. Chaque fournisseur a sa propre file d'attente et sa propre limite de processus.

---

## Conclusion

Une API externe n'est pas une fonction, mais une transaction distribuée avec un participant peu fiable. Les timeouts, les nouvelles tentatives et le Circuit Breaker limitent les dégâts de ses défaillances, tandis que l'idempotence et le rapprochement empêchent ces défaillances de se transformer en doubles débits. Si, après une panne du fournisseur, vous pouvez répondre à la question « le paiement est-il passé ? » sans aller consulter son espace client, votre intégration est bien conçue.

---

<a id="self-test-quiz"></a>
## Quiz d'auto-évaluation

### Question 1 : Une requête de débit a échoué sur un timeout. Quelle est la bonne réaction ?
- A) Relancer immédiatement le débit : un timeout signifie que l'argent n'a pas été débité.
- B) Passer le paiement au statut `unknown` et déterminer le résultat via une requête de statut auprès du fournisseur (ou relancer avec la même clé d'idempotence).
- C) Marquer le paiement comme échoué et demander à l'utilisateur de payer à nouveau.

<details>
<summary><b>Afficher la réponse</b></summary>

**Réponse : B**
Un timeout n'indique pas si l'opération a été exécutée. Une nouvelle tentative à l'aveugle (A) ou un nouveau paiement par l'utilisateur (C) peuvent provoquer un double débit. Il est sûr soit de vérifier le statut, soit de relancer la requête avec la même clé d'idempotence, afin que le fournisseur renvoie le résultat de la première tentative.
</details>

### Question 2 : Pourquoi vérifier « existe-t-il déjà un paiement avec cette clé » via un `SELECT` avant l'`INSERT` ne protège-t-il pas contre les doublons ?
- A) Un `SELECT` est plus lent qu'un index unique.
- B) Deux requêtes parallèles peuvent constater simultanément que l'enregistrement n'existe pas et exécuter toutes les deux l'opération. Seule une contrainte UNIQUE en base protège de façon fiable.
- C) Laravel met en cache les résultats des `SELECT`.

<details>
<summary><b>Afficher la réponse</b></summary>

**Réponse : B**
Entre la vérification et l'insertion, il existe une fenêtre de race condition. L'index unique est vérifié de manière atomique par le SGBD lui-même : le second `INSERT` reçoit donc une `UniqueConstraintViolationException`, et le code renvoie l'opération déjà existante.
</details>

### Question 3 : Pourquoi stocker l'état du Circuit Breaker dans Redis plutôt que dans la mémoire du processus ?
- A) Redis est plus rapide que la mémoire vive du processus.
- B) Pour que tous les workers et tous les nœuds voient le même état du circuit et cessent d'envoyer des requêtes en même temps, plutôt que chacun séparément après sa propre série d'erreurs.
- C) Laravel ne sait pas stocker de données dans la mémoire du processus.

<details>
<summary><b>Afficher la réponse</b></summary>

**Réponse : B**
Avec un état local, chacun des dizaines de workers doit atteindre seul le seuil d'erreurs, et le fournisseur en panne reçoit des dizaines de fois plus de requêtes. Un état partagé ouvre le circuit immédiatement pour l'ensemble du système.
</details>