---
title: 'Integraciones resilientes: timeouts, Retry, Circuit Breaker e idempotencia | DevSense'
description: 'Cómo integrarse con pasarelas de pago y APIs de proveedores sin cobros dobles ni caídas en cascada: timeouts, reintentos con backoff, Circuit Breaker, claves de idempotencia y conciliación en Laravel.'
faq:
    - { question: '¿Por qué el timeout es el resultado más peligroso de una llamada a una API externa?', answer: 'Ante un error, sabe que la operación no se ha realizado; ante un éxito, que sí se ha realizado. Ante un timeout no sabe nada: el proveedor pudo haber cobrado el dinero sin llegar a responder. Un reintento a ciegas en este caso provoca un cobro doble, por eso las operaciones con dinero solo se reintentan con una clave de idempotencia o después de consultar el estado al proveedor.' }
    - { question: '¿Qué errores se pueden reintentar de forma segura?', answer: 'Los errores de conexión de red y las respuestas 429 y 5xx, siempre que la operación sea idempotente o esté protegida por una clave de idempotencia. Reintentar respuestas 4xx (salvo 408 y 429) no tiene sentido: la petición es incorrecta y se rechazará de nuevo. Los reintentos se hacen con retardo exponencial y una dispersión aleatoria (jitter) para no provocarle al proveedor una tormenta de peticiones.' }
    - { question: '¿En qué se diferencia Circuit Breaker de Retry?', answer: 'Retry intenta sobrevivir a un fallo breve de una sola petición. Circuit Breaker protege su sistema de un fallo prolongado del proveedor: tras una serie de errores, deja de enviar peticiones y devuelve de inmediato un error o una alternativa, sin ocupar los workers en esperas. Tras una pausa deja pasar una petición de prueba y, si tiene éxito, vuelve a abrir el tráfico.' }
    - { question: '¿Cómo garantizar que el dinero no se cobre dos veces?', answer: 'No es posible obtener una garantía de «exactamente una vez» en la entrega de mensajes, pero sí lograr exactamente un efecto. Para ello, cada operación recibe una clave única que se guarda en la base de datos con una restricción UNIQUE antes de llamar al proveedor y se le envía en la cabecera Idempotency-Key. Una petición repetida o un webhook repetido con la misma clave devuelve el resultado ya guardado en lugar de crear una operación nueva.' }
published: '2026-09-28'
---
# Integraciones resilientes: timeouts, Retry, Circuit Breaker e idempotencia

La pasarela de pago empezó a responder en 30 segundos en lugar de 300 milisegundos. Un minuto después se cayó todo el sitio, incluidas páginas que no tenían nada que ver con los pagos: todos los workers de PHP-FPM estaban colgados esperando respuesta. Cuando el proveedor se recuperó, salió a la luz lo segundo: parte de los usuarios había pagado el pedido dos veces. El código reintentaba la petición tras el timeout, pero la primera petición en realidad se había completado. Ni una sola línea de código era «incorrecta». Simplemente, la llamada externa se escribió como si fuera la llamada a una función local.

**Guías relacionadas:** [Patrones de microservicios: Saga, CQRS, Circuit Breaker](../microservices/microservice-patterns) · [Transacciones distribuidas](database-and-distributed-transactions) · [Comparativa de colas de mensajes](message-queues-compared)

## Contenido

* [Los tres resultados de una llamada externa](#three-outcomes)
* [Timeouts: un presupuesto, no un valor por defecto](#timeouts)
* [Retry: qué, cuándo y cómo reintentar](#retry)
* [Idempotencia: un efecto en lugar de dos cobros](#idempotency)
* [Webhooks entrantes y cargos concurrentes](#webhooks)
* [Circuit Breaker: dejar de llamar a quien no contesta](#circuit-breaker)
* [Aislamiento: colas separadas para cada proveedor](#bulkheads)
* [Dónde deja de funcionar este enfoque](#limitations)
* [Errores frecuentes](#common-mistakes)
* [Checklist](#checklist)
* [Cuestionario de autoevaluación](#self-test-quiz)

---

<a id="three-outcomes"></a>
## Los tres resultados de una llamada externa

La llamada a un método local tiene dos resultados posibles: devuelve un valor o lanza una excepción. Una llamada por red tiene tres:

1. **Éxito**: el proveedor ha realizado la operación y usted ha recibido la respuesta.
2. **Error**: el proveedor ha respondido que la operación no se ha realizado (o no se ha podido establecer la conexión).
3. **Desconocido**: timeout, conexión cortada después de enviar la petición, un 502 del balanceador. La operación pudo realizarse o no.

**Una integración fiable es código que gestiona explícitamente el tercer resultado: limita el tiempo de espera, reintenta solo las operaciones seguras, deja de llamar a un proveedor que no funciona y, mediante claves de idempotencia, convierte los reintentos en un único efecto.**

Todo lo demás en este artículo son formas de no confundir «desconocido» con «error».

---

<a id="timeouts"></a>
## Timeouts: un presupuesto, no un valor por defecto

El cliente HTTP de Laravel espera por defecto hasta 30 segundos una respuesta. Calculemos lo que eso significa para un sitio con 50 workers de PHP-FPM si el proveedor se «cuelga»:

* cada petición a la página de pago ocupa un worker durante 30 segundos;
* con 2 peticiones por segundo, en 25 segundos están ocupados los 50 workers;
* el resto de páginas del sitio empiezan a responder 502, aunque no les pasa nada.

El timeout es el presupuesto que está dispuesto a conceder al proveedor, en función de su latencia habitual y de cuántos workers puede arriesgarse a ocupar:

```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)  // establecer la conexión TCP/TLS
            ->timeout(5)         // recibir la respuesta completa
            ->acceptJson();
    }
}
```

> [!TIP]
> **Saque las llamadas largas de la petición HTTP del usuario.** Si el proveedor tarda segundos en responder, envíe la petición desde un trabajo en cola y muestre al usuario el estado «pago en proceso». Así el proveedor lento ocupa los workers de la cola, no los workers web.

---

<a id="retry"></a>
## Retry: qué, cuándo y cómo reintentar

Un reintento ayuda a sobrevivir a un fallo breve: el reinicio de un pod en el proveedor, un blip de red, una respuesta `429`. Pero el reintento tiene tres condiciones.

**1. Reintentar solo lo que es seguro reintentar.** Peticiones `GET`, consultas de estado, operaciones con clave de idempotencia. Un cobro sin clave tras un timeout no debe reintentarse nunca.

**2. Reintentar solo los errores reintentables.** Errores de conexión, `429`, `5xx`. Una respuesta `422` o `400` significa que la petición es incorrecta, y el reintento obtendrá la misma respuesta.

**3. Reintentar con una pausa creciente y una dispersión aleatoria.** Si mil clientes repiten la petición exactamente al cabo de un segundo, el proveedor recibirá un golpe sincronizado justo en el momento de recuperarse.

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

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

Para los trabajos en cola, lo mismo se configura mediante propiedades del trabajo:

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

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

> [!WARNING]
> **Los reintentos se multiplican.** Si el cliente HTTP hace 3 intentos, el trabajo en cola 5 y el servicio que llama también reintenta, el proveedor recibirá hasta 15+ peticiones por operación. Reintente en un solo nivel.

---

<a id="idempotency"></a>
## Idempotencia: un efecto en lugar de dos cobros

En una red no se puede obtener una garantía de «exactamente una vez»: o puede perder un mensaje, o puede entregarlo dos veces. El objetivo realista es la **entrega «al menos una vez» más un procesamiento idempotente**, lo que da exactamente un efecto.

Esquema para un cobro saliente:

1. Generar la clave de la operación **antes** de la llamada y guardar la operación en la base de datos con estado `pending` y una restricción UNIQUE sobre la clave.
2. Enviar la clave al proveedor en la cabecera `Idempotency-Key`. Proveedores como Stripe devolverán, ante una petición repetida con la misma clave, el resultado de la primera, en lugar de ejecutar la operación de nuevo.
3. Guardar el resultado. Ante un timeout, poner el estado `unknown` y averiguar la verdad mediante una consulta de estado, no mediante un reintento a ciegas del cobro.

```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 unidades menores (céntimos/centavos)
    $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 repetido o reintento del trabajo: la operación ya existe
            return Payment::where('idempotency_key', $idempotencyKey)->firstOrFail();
        }

        try {
            $response = $this->gateway->charge($payment); // envía Idempotency-Key
            $payment->update([
                'status' => $response['status'] === 'succeeded' ? 'succeeded' : 'failed',
                'provider_payment_id' => $response['id'],
            ]);
        } catch (ConnectionException) {
            // Resultado desconocido: no reintentamos el cobro, comprobamos el estado más tarde
            $payment->update(['status' => 'unknown']);
            ReconcilePayment::dispatch($payment->id)->delay(now()->addMinute());
        }

        return $payment;
    }
}
```

La clave de idempotencia se crea allí donde nace la intención del usuario: por ejemplo, se genera al abrir la página de checkout y se envía en un campo oculto del formulario. Así, un doble clic en el botón «Pagar» también se convierte en una sola operación.

El trabajo `ReconcilePayment` consulta al proveedor el estado por clave o por `provider_payment_id` y pasa el pago a `succeeded` o `failed`. La conciliación no es un parche, sino una parte obligatoria de cualquier integración con dinero: cuando se pasa a `unknown`, solo el proveedor conoce la verdad.

---

<a id="webhooks"></a>
## Webhooks entrantes y cargos concurrentes

Los proveedores entregan los webhooks «al menos una vez»: el mismo evento puede llegar dos veces, simultáneamente a dos nodos, o llegar antes que su respuesta síncrona. El handler debe ser idempotente:

```php
// app/Http/Controllers/Webhooks/GatewayWebhookController.php
public function __invoke(GatewayWebhookRequest $request): Response
{
    $event = $request->validatedEvent(); // incluida la verificación de la firma

    DB::transaction(function () use ($event): void {
        // UNIQUE(provider, event_id): el segundo webhook no insertará nada
        $inserted = DB::table('processed_webhooks')->insertOrIgnore([
            'provider' => 'gateway',
            'event_id' => $event['id'],
            'created_at' => now(),
        ]);

        if ($inserted === 0) {
            return; // ya procesado
        }

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

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

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

Para un saldo interno (monedero, bonificaciones) también hace falta protección frente a la condición de carrera entre dos cargos simultáneos. `SELECT ... FOR UPDATE` serializa las operaciones sobre una misma fila:

```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) en el libro de asientos: otra barrera contra repeticiones
    $wallet->entries()->create(['operation_id' => $operationId, 'amount' => -$amount]);
    $wallet->decrement('balance', $amount);
});
```

Encontrará más detalles sobre bloqueos pesimistas y optimistas en el artículo sobre [transacciones distribuidas](database-and-distributed-transactions).

---

<a id="circuit-breaker"></a>
## Circuit Breaker: dejar de llamar a quien no contesta

Retry y los timeouts salvan una petición individual. Pero si el proveedor está caído diez minutos, cada petición sigue consumiendo todo el presupuesto del timeout y todos los reintentos. Tras una serie de errores, Circuit Breaker **abre el circuito**: las peticiones al proveedor terminan de inmediato con un error o una alternativa, y cada N segundos se deja pasar una petición de prueba.

Laravel no incluye un Circuit Breaker listo para el cliente HTTP, pero con operaciones atómicas de caché (Redis) se escribe en unas pocas decenas de líneas:

```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,   // errores seguidos hasta abrir el circuito
        private int $openSeconds = 30,       // pausa antes de la petición de prueba
    ) {}

    /**
     * @template T
     * @param Closure(): T $call
     * @return T
     */
    public function call(Closure $call): mixed
    {
        if (Cache::has($this->key('open'))) {
            // Dejamos pasar exactamente una petición de prueba tras 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
// Uso
$status = (new CircuitBreaker('gateway'))
    ->call(fn () => $gateway->paymentStatus($paymentId));
```

Para los trabajos en cola, Laravel ya ofrece un análogo cercano: el middleware `ThrottlesExceptions`. Tras un número determinado de excepciones, pospone los siguientes intentos en lugar de machacar un servicio que no funciona:

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

public function middleware(): array
{
    // 10 excepciones → pausa de 5 minutos (en Laravel 11+ el segundo argumento va en segundos)
    return [(new ThrottlesExceptions(10, 5 * 60))->by('provider-games')];
}
```

> [!NOTE]
> **El estado debe ser compartido.** Si el contador de errores se guarda en la memoria del proceso, cada uno de los 50 workers tiene su propio circuito, y el proveedor recibirá 50 × threshold peticiones antes de que todos se abran. Guarde el estado en Redis.

---

<a id="bulkheads"></a>
## Aislamiento: colas separadas para cada proveedor

Incluso con timeouts, un proveedor lento puede ocupar todos los workers de una cola común, y los correos de confirmación de registro tendrán que esperar a que se libere una cola atascada con llamadas a la pasarela de pago. El patrón **Bulkhead (mamparos)** aísla los recursos:

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

Cada proveedor tiene su propia cola y su propio límite de procesos. Cuando un proveedor va lento, solo se acumulan sus trabajos.

---

<a id="limitations"></a>
## Dónde deja de funcionar este enfoque

* **No todos los proveedores admiten claves de idempotencia.** En ese caso, la única protección es la conciliación: antes de reintentar, pedir al proveedor la lista de operaciones por su `order_id` o `reference`. Si tampoco existe esa API, reintentar una operación con dinero sin comprobación manual es inadmisible.
* **Las claves tienen una vida limitada.** Por ejemplo, en Stripe, 24 horas. Un reintento dos días después será una operación nueva.
* **Circuit Breaker puede «protegerle» de un proveedor sano.** Un umbral demasiado bajo abre el circuito por un par de errores aleatorios. Ajuste el umbral y la ventana según las estadísticas reales de errores, no a ojo.
* **La complejidad crece.** Los estados `unknown`, los trabajos de conciliación y el registro de webhooks son código que hay que probar y monitorizar. Para integrarse con una API meteorológica en la página principal basta con un timeout y una caché; todo lo demás es para dinero y pedidos.

---

<a id="common-mistakes"></a>
## Errores frecuentes

**1. El timeout por defecto.**
30 segundos de espera por petición convierten un fallo del proveedor en la caída de todo el sitio. Defina `connectTimeout` y `timeout` de forma explícita.

**2. Reintentar un cobro después de un timeout.**
Un timeout es «desconocido», no «error». Sin clave de idempotencia, el reintento puede cobrar el dinero por segunda vez.

**3. Reintentar respuestas 4xx.**
Una petición incorrecta no se vuelve correcta en el segundo intento. Reintente solo los errores de conexión, `429` y `5xx`.

**4. Reintentos en tres niveles a la vez.**
El cliente HTTP, el trabajo y el servicio que llama multiplican el número de peticiones. Elija un único nivel.

**5. Comprobar duplicados con `SELECT` en lugar de con UNIQUE.**
Dos peticiones concurrentes verán ambas que «no hay registro» y ambas ejecutarán la operación. Solo protege un índice único en la base de datos.

**6. Circuit Breaker en la memoria del proceso.**
Cada worker abre el circuito por su cuenta. Guarde el estado en un almacenamiento compartido.

**7. No hay conciliación.**
Los pagos en estado `unknown` se quedan colgados para siempre y los usuarios escriben a soporte. La conciliación programada es obligatoria.

---

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

1. Cada llamada externa tiene `connectTimeout` y `timeout` explícitos, acordes con el número de workers.
2. Las llamadas largas se han sacado de la petición HTTP del usuario a una cola.
3. Solo se reintentan operaciones idempotentes y solo errores de conexión, `429` y `5xx`, con pausa exponencial y jitter.
4. Las operaciones con dinero reciben una clave de idempotencia antes de la llamada; la clave se guarda con una restricción UNIQUE y se envía al proveedor.
5. Un timeout pasa la operación a `unknown`, tras lo cual se lanza la conciliación.
6. Los webhooks se procesan de forma idempotente: registro de eventos con UNIQUE `(provider, event_id)`.
7. Los cargos al saldo interno se ejecutan bajo `lockForUpdate()`.
8. Circuit Breaker guarda el estado en Redis; para los trabajos se usa `ThrottlesExceptions`.
9. Cada proveedor tiene su propia cola y su límite de procesos.

---

## Resumen

Una API externa no es una función, sino una transacción distribuida con un participante poco fiable. Los timeouts, los reintentos y Circuit Breaker limitan el daño de sus fallos, y la idempotencia y la conciliación hacen que esos fallos no se conviertan en cobros dobles. Si después de un fallo del proveedor puede responder a la pregunta «¿se ha realizado el pago?» sin entrar en su panel de control, la integración está bien diseñada.

---

<a id="self-test-quiz"></a>
## Cuestionario de autoevaluación

### Pregunta 1: Una petición de cobro ha fallado por timeout. ¿Qué es lo correcto?
- A) Reintentar el cobro de inmediato: el timeout significa que no se ha cobrado el dinero.
- B) Pasar el pago al estado `unknown` y averiguar el resultado consultando el estado al proveedor (o reintentar con la misma clave de idempotencia).
- C) Marcar el pago como fallido y pedir al usuario que vuelva a pagar.

<details>
<summary><b>Mostrar respuesta</b></summary>

**Respuesta: B**
Un timeout no indica si la operación se ha ejecutado. Un reintento a ciegas (A) o un nuevo pago por parte del usuario (C) pueden provocar un cobro doble. Lo seguro es comprobar el estado o reintentar la petición con la misma clave de idempotencia para que el proveedor devuelva el resultado del primer intento.
</details>

### Pregunta 2: ¿Por qué comprobar «si ya existe un pago con esta clave» mediante `SELECT` antes del `INSERT` no protege frente a duplicados?
- A) `SELECT` es más lento que un índice único.
- B) Dos peticiones concurrentes pueden ver a la vez que no existe el registro y ejecutar ambas la operación. Solo una restricción UNIQUE en la base de datos protege de forma fiable.
- C) Laravel cachea los resultados de `SELECT`.

<details>
<summary><b>Mostrar respuesta</b></summary>

**Respuesta: B**
Entre la comprobación y la inserción hay una ventana de carrera. El propio SGBD comprueba el índice único de forma atómica, así que el segundo `INSERT` recibirá `UniqueConstraintViolationException` y el código devolverá la operación ya existente.
</details>

### Pregunta 3: ¿Por qué guardar el estado de Circuit Breaker en Redis y no en la memoria del proceso?
- A) Redis es más rápido que la memoria RAM del proceso.
- B) Para que todos los workers y nodos vean el mismo estado del circuito y dejen de enviar peticiones a la vez, y no cada uno por separado tras su propia serie de errores.
- C) Laravel no sabe guardar datos en la memoria del proceso.

<details>
<summary><b>Mostrar respuesta</b></summary>

**Respuesta: B**
Con un estado local, cada una de las decenas de workers tiene que alcanzar por sí misma el umbral de errores, y el proveedor caído recibirá decenas de veces más peticiones. Un estado compartido abre el circuito de inmediato para todo el sistema.
</details>