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