---
title: 'Отказоустойчивые интеграции: таймауты, Retry, Circuit Breaker и идемпотентность | DevSense'
description: 'Как интегрироваться с платёжками и API провайдеров без двойных списаний и каскадных падений: таймауты, повторы с backoff, Circuit Breaker, ключи идемпотентности и сверка в Laravel.'
faq:
    - { question: 'Почему таймаут — самый опасный исход вызова внешнего API?', answer: 'При ошибке вы знаете, что операция не выполнена, при успехе — что выполнена. При таймауте вы не знаете ничего: провайдер мог списать деньги и не успеть ответить. Слепой повтор в этом случае приводит к двойному списанию, поэтому денежные операции повторяют только с ключом идемпотентности или после проверки статуса у провайдера.' }
    - { question: 'Какие ошибки можно безопасно повторять?', answer: 'Сетевые ошибки соединения, ответы 429 и 5xx — если операция идемпотентна или защищена ключом идемпотентности. Ответы 4xx (кроме 408 и 429) повторять бессмысленно: запрос некорректен и будет отклонён снова. Повторы делают с экспоненциальной задержкой и случайным разбросом (jitter), чтобы не устроить провайдеру шторм запросов.' }
    - { question: 'Чем Circuit Breaker отличается от Retry?', answer: 'Retry пытается пережить кратковременный сбой одного запроса. Circuit Breaker защищает вашу систему от долгого сбоя провайдера: после серии ошибок он перестаёт отправлять запросы и сразу возвращает ошибку или запасной вариант, не занимая воркеры ожиданием. Через паузу он пропускает пробный запрос и, если тот успешен, снова открывает трафик.' }
    - { question: 'Как гарантировать, что деньги не спишутся дважды?', answer: 'Гарантию «ровно один раз» для доставки сообщений получить нельзя, но можно добиться ровно одного эффекта. Для этого каждая операция получает уникальный ключ, который сохраняется в базе с UNIQUE-ограничением до вызова провайдера и передаётся ему в заголовке Idempotency-Key. Повторный запрос или повторный вебхук с тем же ключом возвращает уже сохранённый результат вместо новой операции.' }
published: '2026-09-28'
---
# Отказоустойчивые интеграции: таймауты, Retry, Circuit Breaker и идемпотентность

Платёжный провайдер начал отвечать за 30 секунд вместо 300 миллисекунд. Через минуту лёг весь сайт, включая страницы, которые с платежами вообще не связаны: все воркеры PHP-FPM висели в ожидании ответа. Когда провайдер ожил, выяснилось второе: часть пользователей оплатила заказ дважды. Код ретраил запрос после таймаута, а первый запрос на самом деле прошёл. Ни одна строчка кода не была «неправильной». Просто внешний вызов написали так, будто это вызов локальной функции.

**Связанные материалы:** [Паттерны микросервисов: Saga, CQRS, Circuit Breaker](../microservices/microservice-patterns) · [Распределённые транзакции](database-and-distributed-transactions) · [Сравнение очередей сообщений](message-queues-compared)

## Содержание

* [Три исхода внешнего вызова](#three-outcomes)
* [Таймауты: бюджет, а не значение по умолчанию](#timeouts)
* [Retry: что, когда и как повторять](#retry)
* [Идемпотентность: один эффект вместо двух списаний](#idempotency)
* [Входящие вебхуки и конкурентные списания](#webhooks)
* [Circuit Breaker: перестать звонить тому, кто не отвечает](#circuit-breaker)
* [Изоляция: отдельные очереди для каждого провайдера](#bulkheads)
* [Где подход перестаёт работать](#limitations)
* [Частые ошибки](#common-mistakes)
* [Чеклист](#checklist)
* [Квиз для самопроверки](#self-test-quiz)

---

<a id="three-outcomes"></a>
## Три исхода внешнего вызова

У вызова локального метода два исхода: вернул результат или бросил исключение. У вызова по сети их три:

1. **Успех** — провайдер выполнил операцию и вы получили ответ.
2. **Ошибка** — провайдер ответил, что операция не выполнена (или соединение не установилось).
3. **Неизвестно** — таймаут, обрыв соединения после отправки запроса, 502 от балансировщика. Операция могла выполниться, а могла и нет.

**Надёжная интеграция — это код, который явно обрабатывает третий исход: ограничивает время ожидания, повторяет только безопасные операции, перестаёт звонить неработающему провайдеру и через ключи идемпотентности превращает повторы в один эффект.**

Всё остальное в статье — способы не спутать «неизвестно» с «ошибкой».

---

<a id="timeouts"></a>
## Таймауты: бюджет, а не значение по умолчанию

HTTP-клиент Laravel по умолчанию ждёт ответ до 30 секунд. Посчитаем, что это значит для сайта с 50 воркерами PHP-FPM, если провайдер «завис»:

* каждый запрос к странице оплаты занимает воркер на 30 секунд;
* при 2 запросах в секунду через 25 секунд заняты все 50 воркеров;
* остальные страницы сайта начинают отвечать 502, хотя с ними всё в порядке.

Таймаут — это бюджет, который вы готовы отдать провайдеру, исходя из его обычной задержки и того, сколько воркеров можно рискнуть занять:

```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)  // установить TCP/TLS-соединение
            ->timeout(5)         // получить ответ целиком
            ->acceptJson();
    }
}
```

> [!TIP]
> **Уберите долгие вызовы из HTTP-запроса пользователя.** Если провайдер отвечает секунды, отправляйте запрос из задачи в очереди, а пользователю показывайте статус «платёж обрабатывается». Тогда медленный провайдер занимает воркеры очереди, а не веб-воркеры.

---

<a id="retry"></a>
## Retry: что, когда и как повторять

Повтор помогает пережить кратковременный сбой: перезапуск пода у провайдера, сетевой blip, ответ `429`. Но у повтора три условия.

**1. Повторять только то, что безопасно повторять.** `GET`-запросы, проверка статуса, операции с ключом идемпотентности. Списание без ключа после таймаута повторять нельзя никогда.

**2. Повторять только повторяемые ошибки.** Ошибки соединения, `429`, `5xx`. Ответ `422` или `400` означает, что запрос некорректен, и повтор получит тот же ответ.

**3. Повторять с растущей паузой и случайным разбросом.** Если тысяча клиентов повторяет запрос ровно через секунду, провайдер получит синхронный удар в момент восстановления.

```php
// app/Services/Payments/PaymentGatewayClient.php (продолжение)
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Throwable;

public function paymentStatus(string $paymentId): array
{
    return $this->http()
        ->retry(
            3,
            // Экспоненциальная пауза с jitter: ~200, ~400, ~800 мс
            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();
}
```

Для задач в очереди то же самое задаётся свойствами задачи:

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

/** @return list<int> Паузы между попытками в секундах */
public function backoff(): array
{
    return [10, 30, 60, 300];
}
```

> [!WARNING]
> **Повторы умножаются.** Если HTTP-клиент делает 3 попытки, задача в очереди — 5, а вызывающий сервис тоже ретраит, провайдер получит до 15+ запросов на одну операцию. Повторяйте на одном уровне.

---

<a id="idempotency"></a>
## Идемпотентность: один эффект вместо двух списаний

Гарантию «ровно один раз» в сети получить нельзя: либо вы можете потерять сообщение, либо можете доставить его дважды. Реалистичная цель — **доставка «хотя бы раз» плюс идемпотентная обработка**, что даёт ровно один эффект.

Схема для исходящего списания:

1. Сгенерировать ключ операции **до** вызова и сохранить её в базе со статусом `pending` и UNIQUE-ограничением на ключ.
2. Передать ключ провайдеру в заголовке `Idempotency-Key`. Провайдеры вроде Stripe вернут на повторный запрос с тем же ключом результат первого, а не проведут операцию снова.
3. Сохранить результат. При таймауте поставить статус `unknown` и выяснить правду через запрос статуса, а не через слепой повтор списания.

```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');         // в минорных единицах (копейки/центы)
    $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) {
            // Повторный клик или повтор задачи: операция уже существует
            return Payment::where('idempotency_key', $idempotencyKey)->firstOrFail();
        }

        try {
            $response = $this->gateway->charge($payment); // передаёт Idempotency-Key
            $payment->update([
                'status' => $response['status'] === 'succeeded' ? 'succeeded' : 'failed',
                'provider_payment_id' => $response['id'],
            ]);
        } catch (ConnectionException) {
            // Исход неизвестен: не повторяем списание, а проверяем статус позже
            $payment->update(['status' => 'unknown']);
            ReconcilePayment::dispatch($payment->id)->delay(now()->addMinute());
        }

        return $payment;
    }
}
```

Ключ идемпотентности создаётся там, где рождается намерение пользователя: например, генерируется при открытии страницы оформления заказа и передаётся скрытым полем формы. Тогда двойной клик по кнопке «Оплатить» тоже превращается в одну операцию.

Задача `ReconcilePayment` запрашивает у провайдера статус по ключу или `provider_payment_id` и переводит платёж в `succeeded` или `failed`. Сверка — не костыль, а обязательная часть любой денежной интеграции: при переходе в `unknown` правду знает только провайдер.

---

<a id="webhooks"></a>
## Входящие вебхуки и конкурентные списания

Провайдеры доставляют вебхуки «хотя бы раз»: одно и то же событие может прийти дважды, одновременно на две ноды, или прийти раньше, чем ваш синхронный ответ. Обработчик должен быть идемпотентным:

```php
// app/Http/Controllers/Webhooks/GatewayWebhookController.php
public function __invoke(GatewayWebhookRequest $request): Response
{
    $event = $request->validatedEvent(); // включая проверку подписи

    DB::transaction(function () use ($event): void {
        // UNIQUE(provider, event_id): второй вебхук ничего не вставит
        $inserted = DB::table('processed_webhooks')->insertOrIgnore([
            'provider' => 'gateway',
            'event_id' => $event['id'],
            'created_at' => now(),
        ]);

        if ($inserted === 0) {
            return; // уже обработан
        }

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

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

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

Для внутреннего баланса (кошелька, бонусов) нужна ещё и защита от гонки двух одновременных списаний. `SELECT ... FOR UPDATE` сериализует операции над одной строкой:

```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) в журнале проводок — ещё один барьер от повтора
    $wallet->entries()->create(['operation_id' => $operationId, 'amount' => -$amount]);
    $wallet->decrement('balance', $amount);
});
```

Подробнее о пессимистичных и оптимистичных блокировках — в статье о [распределённых транзакциях](database-and-distributed-transactions).

---

<a id="circuit-breaker"></a>
## Circuit Breaker: перестать звонить тому, кто не отвечает

Retry и таймауты спасают отдельный запрос. Но если провайдер лежит десять минут, каждый запрос всё равно тратит весь бюджет таймаута и все повторы. Circuit Breaker после серии ошибок **размыкает цепь**: запросы к провайдеру сразу завершаются ошибкой или запасным вариантом, а раз в N секунд пропускается пробный запрос.

В Laravel нет готового Circuit Breaker для HTTP-клиента, но на атомарных операциях кэша (Redis) он пишется в несколько десятков строк:

```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,   // ошибок подряд до размыкания
        private int $openSeconds = 30,       // пауза перед пробным запросом
    ) {}

    /**
     * @template T
     * @param Closure(): T $call
     * @return T
     */
    public function call(Closure $call): mixed
    {
        if (Cache::has($this->key('open'))) {
            // Пропускаем ровно один пробный запрос после паузы (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
// Использование
$status = (new CircuitBreaker('gateway'))
    ->call(fn () => $gateway->paymentStatus($paymentId));
```

Для задач в очереди в Laravel уже есть близкий аналог — middleware `ThrottlesExceptions`: после заданного числа исключений он откладывает следующие попытки, вместо того чтобы долбить неработающий сервис:

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

public function middleware(): array
{
    // 10 исключений → пауза 5 минут (в Laravel 11+ второй аргумент в секундах)
    return [(new ThrottlesExceptions(10, 5 * 60))->by('provider-games')];
}
```

> [!NOTE]
> **Состояние должно быть общим.** Если счётчик ошибок хранится в памяти процесса, у каждого из 50 воркеров своя цепь, и провайдер получит 50 × threshold запросов, прежде чем все они разомкнутся. Храните состояние в Redis.

---

<a id="bulkheads"></a>
## Изоляция: отдельные очереди для каждого провайдера

Даже с таймаутами медленный провайдер может занять всех воркеров общей очереди, и письма с подтверждением регистрации будут ждать, пока не освободится очередь, забитая вызовами платёжки. Паттерн **Bulkhead (переборки)** изолирует ресурсы:

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

У каждого провайдера — своя очередь и свой лимит процессов. Когда один провайдер тормозит, копятся только его задачи.

---

<a id="limitations"></a>
## Где подход перестаёт работать

* **Не все провайдеры поддерживают ключи идемпотентности.** Тогда единственная защита — сверка: перед повтором запросить у провайдера список операций по вашему `order_id` или `reference`. Если нет и такого API, повтор денежной операции без ручной проверки недопустим.
* **Ключи живут ограниченное время.** Например, у Stripe — 24 часа. Повтор через двое суток будет новой операцией.
* **Circuit Breaker может «защитить» от здорового провайдера.** Слишком низкий порог размыкает цепь из-за пары случайных ошибок. Подбирайте порог и окно по реальной статистике ошибок, а не наугад.
* **Сложность растёт.** Статусы `unknown`, задачи сверки, журнал вебхуков — это код, который нужно тестировать и мониторить. Для интеграции с API погоды на главной странице хватит таймаута и кэша, всё остальное — для денег и заказов.

---

<a id="common-mistakes"></a>
## Частые ошибки

**1. Таймаут по умолчанию.**
30 секунд ожидания на каждый запрос превращают сбой провайдера в падение всего сайта. Задавайте `connectTimeout` и `timeout` явно.

**2. Повтор списания после таймаута.**
Таймаут — это «неизвестно», а не «ошибка». Без ключа идемпотентности повтор может списать деньги второй раз.

**3. Повтор 4xx-ответов.**
Некорректный запрос не станет корректным со второй попытки. Повторяйте только ошибки соединения, `429` и `5xx`.

**4. Повторы на трёх уровнях сразу.**
HTTP-клиент, задача и вызывающий сервис умножают число запросов. Выберите один уровень.

**5. Проверка дубликата через `SELECT`, а не через UNIQUE.**
Два параллельных запроса оба увидят «записи нет» и оба выполнят операцию. Защищает только уникальный индекс в базе.

**6. Circuit Breaker в памяти процесса.**
Каждый воркер размыкает цепь сам по себе. Храните состояние в общем хранилище.

**7. Нет сверки.**
Платежи в статусе `unknown` висят вечно, а пользователи пишут в поддержку. Сверка по расписанию обязательна.

---

<a id="checklist"></a>
## Чеклист

1. У каждого внешнего вызова явные `connectTimeout` и `timeout`, согласованные с числом воркеров.
2. Долгие вызовы вынесены из HTTP-запроса пользователя в очередь.
3. Повторяются только идемпотентные операции и только ошибки соединения, `429` и `5xx`, с экспоненциальной паузой и jitter.
4. Денежные операции получают ключ идемпотентности до вызова; ключ хранится с UNIQUE-ограничением и передаётся провайдеру.
5. Таймаут переводит операцию в `unknown`, после чего запускается сверка.
6. Вебхуки обрабатываются идемпотентно: журнал событий с UNIQUE `(provider, event_id)`.
7. Списания с внутреннего баланса выполняются под `lockForUpdate()`.
8. Circuit Breaker хранит состояние в Redis; для задач используется `ThrottlesExceptions`.
9. У каждого провайдера отдельная очередь и лимит процессов.

---

## Итог

Внешний API — это не функция, а распределённая транзакция с ненадёжным участником. Таймауты, повторы и Circuit Breaker ограничивают ущерб от его сбоев, а идемпотентность и сверка делают так, что сбои не превращаются в двойные списания. Если после сбоя провайдера вы можете ответить на вопрос «прошёл ли платёж», не заглядывая в его личный кабинет, — интеграция спроектирована правильно.

---

<a id="self-test-quiz"></a>
## Квиз для самопроверки

### Вопрос 1: Запрос на списание упал с таймаутом. Что правильно сделать?
- А) Сразу повторить списание: таймаут означает, что деньги не списаны.
- Б) Перевести платёж в статус `unknown` и выяснить результат через запрос статуса у провайдера (или повторить с тем же ключом идемпотентности).
- В) Пометить платёж как неуспешный и попросить пользователя оплатить снова.

<details>
<summary>Показать правильный ответ</summary>

**Правильный ответ: Б**
Таймаут не говорит, выполнилась ли операция. Слепой повтор (А) или повторная оплата пользователем (В) могут привести к двойному списанию. Безопасно либо проверить статус, либо повторить запрос с тем же ключом идемпотентности, чтобы провайдер вернул результат первой попытки.
</details>

### Вопрос 2: Почему проверка «есть ли уже платёж с таким ключом» через `SELECT` перед `INSERT` не защищает от дублей?
- А) `SELECT` работает медленнее уникального индекса.
- Б) Два параллельных запроса могут одновременно увидеть, что записи нет, и оба выполнить операцию. Надёжно защищает только UNIQUE-ограничение в базе.
- В) Laravel кэширует результаты `SELECT`.

<details>
<summary>Показать правильный ответ</summary>

**Правильный ответ: Б**
Между проверкой и вставкой есть окно гонки. Уникальный индекс проверяется самой СУБД атомарно, поэтому второй `INSERT` получит `UniqueConstraintViolationException`, и код вернёт уже существующую операцию.
</details>

### Вопрос 3: Зачем хранить состояние Circuit Breaker в Redis, а не в памяти процесса?
- А) Redis быстрее оперативной памяти процесса.
- Б) Чтобы все воркеры и ноды видели одно состояние цепи и перестали отправлять запросы одновременно, а не каждый по отдельности после своей серии ошибок.
- В) Laravel не умеет хранить данные в памяти процесса.

<details>
<summary>Показать правильный ответ</summary>

**Правильный ответ: Б**
При локальном состоянии каждый из десятков воркеров должен сам набрать порог ошибок, и неработающий провайдер получит в десятки раз больше запросов. Общее состояние размыкает цепь сразу для всей системы.
</details>