---
title: 'Integrazioni resilienti: timeout, Retry, Circuit Breaker e idempotenza | DevSense'
description: 'Come integrarsi con gateway di pagamento e API di provider senza doppi addebiti né guasti a cascata: timeout, retry con backoff, Circuit Breaker, chiavi di idempotenza e riconciliazione in Laravel.'
faq:
    - { question: "Perché il timeout è l'esito più pericoloso di una chiamata a un'API esterna?", answer: "In caso di errore sapete che l'operazione non è stata eseguita, in caso di successo sapete che lo è stata. In caso di timeout non sapete nulla: il provider potrebbe aver addebitato il denaro senza fare in tempo a rispondere. Un retry alla cieca in questa situazione porta a un doppio addebito, per questo le operazioni monetarie si ripetono solo con una chiave di idempotenza o dopo aver verificato lo stato presso il provider." }
    - { question: 'Quali errori si possono ripetere in sicurezza?', answer: "Gli errori di connessione di rete e le risposte 429 e 5xx, se l'operazione è idempotente o protetta da una chiave di idempotenza. Ripetere le risposte 4xx (tranne 408 e 429) non ha senso: la richiesta non è valida e verrà rifiutata di nuovo. I retry si eseguono con un ritardo esponenziale e una variazione casuale (jitter), per non scatenare sul provider una tempesta di richieste." }
    - { question: 'In cosa differisce un Circuit Breaker da un Retry?', answer: "Il Retry cerca di superare un guasto momentaneo di una singola richiesta. Il Circuit Breaker protegge il vostro sistema da un guasto prolungato del provider: dopo una serie di errori smette di inviare richieste e restituisce subito un errore o un'alternativa di ripiego, senza tenere occupati i worker in attesa. Dopo una pausa lascia passare una richiesta di prova e, se questa va a buon fine, riapre il traffico." }
    - { question: 'Come garantire che il denaro non venga addebitato due volte?', answer: "Non è possibile ottenere la garanzia «esattamente una volta» per la consegna dei messaggi, ma si può ottenere esattamente un effetto. Per farlo, ogni operazione riceve una chiave univoca, che viene salvata nel database con un vincolo UNIQUE prima della chiamata al provider e gli viene passata nell'header Idempotency-Key. Una richiesta ripetuta o un webhook ripetuto con la stessa chiave restituisce il risultato già salvato invece di creare una nuova operazione." }
published: '2026-09-28'
---
# Integrazioni resilienti: timeout, Retry, Circuit Breaker e idempotenza

Il provider di pagamento ha iniziato a rispondere in 30 secondi invece di 300 millisecondi. Dopo un minuto è crollato l'intero sito, comprese le pagine che con i pagamenti non hanno nulla a che fare: tutti i worker PHP-FPM erano bloccati in attesa di una risposta. Quando il provider si è ripreso, è emerso un secondo problema: una parte degli utenti aveva pagato l'ordine due volte. Il codice ripeteva la richiesta dopo il timeout, ma in realtà la prima richiesta era andata a buon fine. Nessuna riga di codice era «sbagliata». Semplicemente, la chiamata esterna era stata scritta come se fosse la chiamata a una funzione locale.

**Materiali correlati:** [Pattern dei microservizi: Saga, CQRS, Circuit Breaker](../microservices/microservice-patterns) · [Transazioni distribuite](database-and-distributed-transactions) · [Code di messaggi a confronto](message-queues-compared)

## Indice

* [I tre esiti di una chiamata esterna](#three-outcomes)
* [Timeout: un budget, non un valore predefinito](#timeouts)
* [Retry: cosa, quando e come ripetere](#retry)
* [Idempotenza: un solo effetto invece di due addebiti](#idempotency)
* [Webhook in ingresso e addebiti concorrenti](#webhooks)
* [Circuit Breaker: smettere di chiamare chi non risponde](#circuit-breaker)
* [Isolamento: code separate per ogni provider](#bulkheads)
* [Dove l'approccio smette di funzionare](#limitations)
* [Errori comuni](#common-mistakes)
* [Checklist](#checklist)
* [Quiz di autoverifica](#self-test-quiz)

---

<a id="three-outcomes"></a>
## I tre esiti di una chiamata esterna

La chiamata a un metodo locale ha due esiti: restituisce un risultato o lancia un'eccezione. Una chiamata di rete ne ha tre:

1. **Successo**: il provider ha eseguito l'operazione e avete ricevuto la risposta.
2. **Errore**: il provider ha risposto che l'operazione non è stata eseguita (oppure la connessione non si è stabilita).
3. **Sconosciuto**: timeout, connessione interrotta dopo l'invio della richiesta, 502 dal load balancer. L'operazione potrebbe essere stata eseguita oppure no.

**Un'integrazione affidabile è codice che gestisce esplicitamente il terzo esito: limita il tempo di attesa, ripete solo le operazioni sicure, smette di chiamare un provider non funzionante e, grazie alle chiavi di idempotenza, trasforma i retry in un unico effetto.**

Tutto il resto dell'articolo riguarda i modi per non confondere «sconosciuto» con «errore».

---

<a id="timeouts"></a>
## Timeout: un budget, non un valore predefinito

Per impostazione predefinita il client HTTP di Laravel attende una risposta fino a 30 secondi. Calcoliamo cosa significa per un sito con 50 worker PHP-FPM se il provider «si blocca»:

* ogni richiesta alla pagina di pagamento occupa un worker per 30 secondi;
* con 2 richieste al secondo, dopo 25 secondi tutti i 50 worker sono occupati;
* le altre pagine del sito iniziano a rispondere con 502, anche se non hanno alcun problema.

Il timeout è il budget che siete disposti a concedere al provider, in base alla sua latenza abituale e a quanti worker potete rischiare di occupare:

```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)  // stabilire la connessione TCP/TLS
            ->timeout(5)         // ricevere la risposta completa
            ->acceptJson();
    }
}
```

> [!TIP]
> **Togliete le chiamate lunghe dalla richiesta HTTP dell'utente.** Se il provider risponde in secondi, inviate la richiesta da un job in coda e mostrate all'utente lo stato «pagamento in elaborazione». In questo modo un provider lento occupa i worker della coda, non i worker web.

---

<a id="retry"></a>
## Retry: cosa, quando e come ripetere

Un retry aiuta a superare un guasto momentaneo: il riavvio di un pod presso il provider, un blip di rete, una risposta `429`. Ma il retry ha tre condizioni.

**1. Ripetere solo ciò che è sicuro ripetere.** Richieste `GET`, verifica dello stato, operazioni con chiave di idempotenza. Un addebito senza chiave dopo un timeout non va mai ripetuto.

**2. Ripetere solo gli errori ripetibili.** Errori di connessione, `429`, `5xx`. Una risposta `422` o `400` significa che la richiesta non è valida e il retry otterrà la stessa risposta.

**3. Ripetere con una pausa crescente e una variazione casuale.** Se mille client ripetono la richiesta esattamente dopo un secondo, il provider riceverà un colpo sincronizzato proprio nel momento in cui si sta riprendendo.

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

public function paymentStatus(string $paymentId): array
{
    return $this->http()
        ->retry(
            3,
            // Pausa esponenziale con 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();
}
```

Per i job in coda la stessa cosa si imposta tramite le proprietà del job:

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

/** @return list<int> Pause tra i tentativi in secondi */
public function backoff(): array
{
    return [10, 30, 60, 300];
}
```

> [!WARNING]
> **I retry si moltiplicano.** Se il client HTTP esegue 3 tentativi, il job in coda 5 e anche il servizio chiamante ripete, il provider riceverà fino a 15+ richieste per una sola operazione. Ripetete su un solo livello.

---

<a id="idempotency"></a>
## Idempotenza: un solo effetto invece di due addebiti

In rete non si può ottenere la garanzia «esattamente una volta»: o rischiate di perdere il messaggio, o rischiate di consegnarlo due volte. L'obiettivo realistico è **la consegna «almeno una volta» più l'elaborazione idempotente**, che produce esattamente un effetto.

Schema per un addebito in uscita:

1. Generare la chiave dell'operazione **prima** della chiamata e salvare l'operazione nel database con stato `pending` e un vincolo UNIQUE sulla chiave.
2. Passare la chiave al provider nell'header `Idempotency-Key`. Provider come Stripe, a una richiesta ripetuta con la stessa chiave, restituiscono il risultato della prima invece di eseguire di nuovo l'operazione.
3. Salvare il risultato. In caso di timeout impostare lo stato `unknown` e scoprire la verità tramite una richiesta di stato, non tramite un retry alla cieca dell'addebito.

```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');         // in unità minori (centesimi)
    $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) {
            // Clic ripetuto o retry del job: l'operazione esiste già
            return Payment::where('idempotency_key', $idempotencyKey)->firstOrFail();
        }

        try {
            $response = $this->gateway->charge($payment); // passa l'Idempotency-Key
            $payment->update([
                'status' => $response['status'] === 'succeeded' ? 'succeeded' : 'failed',
                'provider_payment_id' => $response['id'],
            ]);
        } catch (ConnectionException) {
            // Esito sconosciuto: non ripetiamo l'addebito, verifichiamo lo stato più tardi
            $payment->update(['status' => 'unknown']);
            ReconcilePayment::dispatch($payment->id)->delay(now()->addMinute());
        }

        return $payment;
    }
}
```

La chiave di idempotenza si crea là dove nasce l'intenzione dell'utente: per esempio, viene generata all'apertura della pagina di checkout e passata come campo nascosto del form. Così anche un doppio clic sul pulsante «Paga» si trasforma in un'unica operazione.

Il job `ReconcilePayment` chiede al provider lo stato tramite la chiave o `provider_payment_id` e porta il pagamento in `succeeded` o `failed`. La riconciliazione non è una toppa, ma una parte obbligatoria di qualsiasi integrazione che muove denaro: quando si passa a `unknown`, la verità la conosce solo il provider.

---

<a id="webhooks"></a>
## Webhook in ingresso e addebiti concorrenti

I provider consegnano i webhook «almeno una volta»: lo stesso evento può arrivare due volte, contemporaneamente su due nodi, oppure prima della vostra risposta sincrona. L'handler deve essere idempotente:

```php
// app/Http/Controllers/Webhooks/GatewayWebhookController.php
public function __invoke(GatewayWebhookRequest $request): Response
{
    $event = $request->validatedEvent(); // inclusa la verifica della firma

    DB::transaction(function () use ($event): void {
        // UNIQUE(provider, event_id): il secondo webhook non inserirà nulla
        $inserted = DB::table('processed_webhooks')->insertOrIgnore([
            'provider' => 'gateway',
            'event_id' => $event['id'],
            'created_at' => now(),
        ]);

        if ($inserted === 0) {
            return; // già elaborato
        }

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

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

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

Per un saldo interno (wallet, bonus) serve anche una protezione dalla race condition tra due addebiti simultanei. `SELECT ... FOR UPDATE` serializza le operazioni su una stessa riga:

```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) nel registro delle scritture contabili: un'ulteriore barriera contro le ripetizioni
    $wallet->entries()->create(['operation_id' => $operationId, 'amount' => -$amount]);
    $wallet->decrement('balance', $amount);
});
```

Per approfondire il locking pessimistico e ottimistico, vedete l'articolo sulle [transazioni distribuite](database-and-distributed-transactions).

---

<a id="circuit-breaker"></a>
## Circuit Breaker: smettere di chiamare chi non risponde

Retry e timeout salvano la singola richiesta. Ma se il provider è giù per dieci minuti, ogni richiesta consuma comunque l'intero budget di timeout e tutti i retry. Il Circuit Breaker, dopo una serie di errori, **apre il circuito**: le richieste verso il provider terminano subito con un errore o con un'alternativa di ripiego, e ogni N secondi viene lasciata passare una richiesta di prova.

Laravel non offre un Circuit Breaker pronto per il client HTTP, ma con le operazioni atomiche della cache (Redis) lo si scrive in poche decine di righe:

```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,   // errori consecutivi prima dell'apertura
        private int $openSeconds = 30,       // pausa prima della richiesta di prova
    ) {}

    /**
     * @template T
     * @param Closure(): T $call
     * @return T
     */
    public function call(Closure $call): mixed
    {
        if (Cache::has($this->key('open'))) {
            // Lasciamo passare esattamente una richiesta di prova dopo la pausa (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
// Utilizzo
$status = (new CircuitBreaker('gateway'))
    ->call(fn () => $gateway->paymentStatus($paymentId));
```

Per i job in coda Laravel offre già un analogo molto simile, il middleware `ThrottlesExceptions`: dopo un certo numero di eccezioni rimanda i tentativi successivi, invece di continuare a martellare un servizio non funzionante:

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

public function middleware(): array
{
    // 10 eccezioni → pausa di 5 minuti (in Laravel 11+ il secondo argomento è in secondi)
    return [(new ThrottlesExceptions(10, 5 * 60))->by('provider-games')];
}
```

> [!NOTE]
> **Lo stato deve essere condiviso.** Se il contatore degli errori è conservato nella memoria del processo, ciascuno dei 50 worker ha il proprio circuito e il provider riceverà 50 × threshold richieste prima che si aprano tutti. Conservate lo stato in Redis.

---

<a id="bulkheads"></a>
## Isolamento: code separate per ogni provider

Anche con i timeout, un provider lento può occupare tutti i worker di una coda condivisa, e le email di conferma della registrazione dovranno aspettare che si liberi una coda intasata dalle chiamate al gateway di pagamento. Il pattern **Bulkhead (paratie)** isola le risorse:

```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
```

Ogni provider ha la propria coda e il proprio limite di processi. Quando un provider rallenta, si accumulano solo i suoi job.

---

<a id="limitations"></a>
## Dove l'approccio smette di funzionare

* **Non tutti i provider supportano le chiavi di idempotenza.** In quel caso l'unica protezione è la riconciliazione: prima del retry, chiedere al provider l'elenco delle operazioni per il vostro `order_id` o `reference`. Se manca anche un'API del genere, ripetere un'operazione monetaria senza una verifica manuale è inammissibile.
* **Le chiavi hanno una durata limitata.** Per esempio, in Stripe 24 ore. Un retry dopo due giorni sarà una nuova operazione.
* **Il Circuit Breaker può «proteggervi» da un provider sano.** Una soglia troppo bassa apre il circuito per un paio di errori casuali. Scegliete la soglia e la finestra in base alle statistiche reali degli errori, non a caso.
* **La complessità cresce.** Stati `unknown`, job di riconciliazione, registro dei webhook: è codice che va testato e monitorato. Per l'integrazione con un'API meteo in homepage bastano un timeout e una cache; tutto il resto serve per denaro e ordini.

---

<a id="common-mistakes"></a>
## Errori comuni

**1. Timeout predefinito.**
30 secondi di attesa per ogni richiesta trasformano un guasto del provider nel crollo dell'intero sito. Impostate `connectTimeout` e `timeout` in modo esplicito.

**2. Retry dell'addebito dopo un timeout.**
Il timeout significa «sconosciuto», non «errore». Senza una chiave di idempotenza, il retry può addebitare il denaro una seconda volta.

**3. Retry delle risposte 4xx.**
Una richiesta non valida non diventa valida al secondo tentativo. Ripetete solo gli errori di connessione, `429` e `5xx`.

**4. Retry su tre livelli contemporaneamente.**
Client HTTP, job e servizio chiamante moltiplicano il numero di richieste. Scegliete un solo livello.

**5. Controllo dei duplicati tramite `SELECT` anziché tramite UNIQUE.**
Due richieste parallele vedranno entrambe «il record non esiste» ed eseguiranno entrambe l'operazione. Protegge solo un indice univoco nel database.

**6. Circuit Breaker nella memoria del processo.**
Ogni worker apre il circuito per conto proprio. Conservate lo stato in uno storage condiviso.

**7. Nessuna riconciliazione.**
I pagamenti in stato `unknown` restano sospesi per sempre e gli utenti scrivono al supporto. La riconciliazione pianificata è obbligatoria.

---

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

1. Ogni chiamata esterna ha `connectTimeout` e `timeout` espliciti, commisurati al numero di worker.
2. Le chiamate lunghe sono spostate dalla richiesta HTTP dell'utente in una coda.
3. Si ripetono solo le operazioni idempotenti e solo gli errori di connessione, `429` e `5xx`, con pausa esponenziale e jitter.
4. Le operazioni monetarie ricevono una chiave di idempotenza prima della chiamata; la chiave è salvata con un vincolo UNIQUE e passata al provider.
5. Un timeout porta l'operazione in `unknown`, dopodiché parte la riconciliazione.
6. I webhook vengono elaborati in modo idempotente: registro degli eventi con UNIQUE `(provider, event_id)`.
7. Gli addebiti sul saldo interno vengono eseguiti sotto `lockForUpdate()`.
8. Il Circuit Breaker conserva lo stato in Redis; per i job si usa `ThrottlesExceptions`.
9. Ogni provider ha una coda separata e un limite di processi.

---

## Conclusione

Un'API esterna non è una funzione, ma una transazione distribuita con un partecipante inaffidabile. Timeout, retry e Circuit Breaker limitano i danni dei suoi guasti, mentre idempotenza e riconciliazione fanno sì che i guasti non si trasformino in doppi addebiti. Se dopo un guasto del provider riuscite a rispondere alla domanda «il pagamento è andato a buon fine?» senza aprire il suo pannello di controllo, l'integrazione è progettata correttamente.

---

<a id="self-test-quiz"></a>
## Quiz di autoverifica

### Domanda 1: Una richiesta di addebito è fallita per timeout. Qual è la cosa giusta da fare?
- A) Ripetere subito l'addebito: il timeout significa che il denaro non è stato addebitato.
- B) Portare il pagamento nello stato `unknown` e scoprire il risultato tramite una richiesta di stato al provider (oppure ripetere con la stessa chiave di idempotenza).
- C) Contrassegnare il pagamento come non riuscito e chiedere all'utente di pagare di nuovo.

<details>
<summary><b>Mostra la risposta</b></summary>

**Risposta: B**
Il timeout non dice se l'operazione è stata eseguita. Un retry alla cieca (A) o un nuovo pagamento da parte dell'utente (C) possono portare a un doppio addebito. È sicuro o verificare lo stato, o ripetere la richiesta con la stessa chiave di idempotenza, così che il provider restituisca il risultato del primo tentativo.
</details>

### Domanda 2: Perché il controllo «esiste già un pagamento con questa chiave?» tramite `SELECT` prima di `INSERT` non protegge dai duplicati?
- A) `SELECT` è più lento di un indice univoco.
- B) Due richieste parallele possono vedere contemporaneamente che il record non esiste ed eseguire entrambe l'operazione. Protegge in modo affidabile solo un vincolo UNIQUE nel database.
- C) Laravel mette in cache i risultati di `SELECT`.

<details>
<summary><b>Mostra la risposta</b></summary>

**Risposta: B**
Tra il controllo e l'inserimento c'è una finestra di race condition. L'indice univoco viene verificato in modo atomico dal DBMS stesso, quindi il secondo `INSERT` riceverà `UniqueConstraintViolationException` e il codice restituirà l'operazione già esistente.
</details>

### Domanda 3: Perché conservare lo stato del Circuit Breaker in Redis e non nella memoria del processo?
- A) Redis è più veloce della memoria RAM del processo.
- B) Perché tutti i worker e i nodi vedano lo stesso stato del circuito e smettano di inviare richieste contemporaneamente, anziché ciascuno per conto proprio dopo la propria serie di errori.
- C) Laravel non è in grado di conservare dati nella memoria del processo.

<details>
<summary><b>Mostra la risposta</b></summary>

**Risposta: B**
Con uno stato locale, ciascuna delle decine di worker deve raggiungere da sé la soglia di errori, e il provider non funzionante riceverà decine di volte più richieste. Lo stato condiviso apre il circuito subito per l'intero sistema.
</details>