---
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 пази вашата система от продължителен срив на доставчика: след поредица от грешки спира да изпраща заявки и веднага връща грешка или резервен вариант, без да държи worker-ите в чакане. След пауза пропуска една пробна заявка и ако тя е успешна, отново пуска трафика.' }
    - { question: 'Как да гарантирате, че парите няма да бъдат изтеглени два пъти?', answer: 'Гаранция „точно веднъж“ за доставката на съобщения не може да се постигне, но може да се постигне точно един ефект. За целта всяка операция получава уникален ключ, който се записва в базата с UNIQUE ограничение преди извикването на доставчика и му се подава в заглавката Idempotency-Key. Повторна заявка или повторен webhook със същия ключ връща вече записания резултат вместо нова операция.' }
published: '2026-09-28'
---
# Устойчиви интеграции: таймаути, Retry, Circuit Breaker и идемпотентност

Платежният доставчик започна да отговаря за 30 секунди вместо за 300 милисекунди. След минута падна целият сайт, включително страници, които изобщо нямат връзка с плащанията: всички worker-и на PHP-FPM висяха в очакване на отговор. Когато доставчикът се съвзе, стана ясно и второто: част от потребителите бяха платили поръчката си два пъти. Кодът повтаряше заявката след таймаут, а първата заявка всъщност беше минала. Нито един ред от кода не беше „грешен“. Просто външното извикване беше написано така, сякаш е извикване на локална функция.

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

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

* [Трите изхода от външно извикване](#three-outcomes)
* [Таймаути: бюджет, а не стойност по подразбиране](#timeouts)
* [Retry: какво, кога и как да повтаряме](#retry)
* [Идемпотентност: един ефект вместо две таксувания](#idempotency)
* [Входящи webhook-ове и конкурентни таксувания](#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 worker-а на PHP-FPM, ако доставчикът „забие“:

* всяка заявка към страницата за плащане заема worker за 30 секунди;
* при 2 заявки в секунда след 25 секунди и 50-те worker-а са заети;
* останалите страници на сайта започват да връщат 502, макар че при тях всичко е наред.

Таймаутът е бюджетът, който сте готови да дадете на доставчика, според обичайната му латентност и според това колко worker-а можете да рискувате да заемете:

```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 заявката на потребителя.** Ако доставчикът отговаря за секунди, изпращайте заявката от задача в опашката, а на потребителя показвайте статус „плащането се обработва“. Така бавният доставчик заема worker-ите на опашката, а не уеб worker-ите.

---

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

Повторният опит помага да се преживее краткотраен срив: рестарт на pod при доставчика, мрежов 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 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();
}
```

За задачи в опашката същото се задава чрез свойствата на задачата:

```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>
## Входящи webhook-ове и конкурентни таксувания

Доставчиците доставят webhook-овете „поне веднъж“: едно и също събитие може да пристигне два пъти, едновременно на два нода или преди синхронния ви отговор. Обработчикът трябва да е идемпотентен:

```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): вторият webhook няма да вмъкне нищо
        $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-те worker-а има своя верига и доставчикът ще получи 50 × threshold заявки, преди всички те да се прекъснат. Пазете състоянието в Redis.

---

<a id="bulkheads"></a>
## Изолация: отделни опашки за всеки доставчик

Дори с таймаути бавен доставчик може да заеме всички worker-и на общата опашка и имейлите за потвърждение на регистрация ще чакат, докато се освободи опашката, задръстена с извиквания към платежната система. Шаблонът **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`, задачи за сверяване, журнал на webhook-овете — това е код, който трябва да се тества и наблюдава. За интеграция с API за времето на началната страница стигат таймаут и кеш; всичко останало е за парите и поръчките.

---

<a id="common-mistakes"></a>
## Чести грешки

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

**2. Повторно таксуване след таймаут.**
Таймаутът е „неизвестно“, а не „грешка“. Без ключ за идемпотентност повторният опит може да изтегли парите втори път.

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

**4. Повторни опити на три нива едновременно.**
HTTP клиентът, задачата и извикващата услуга умножават броя на заявките. Изберете едно ниво.

**5. Проверка за дубликат чрез `SELECT`, а не чрез UNIQUE.**
Две паралелни заявки и двете ще видят „няма запис“ и и двете ще изпълнят операцията. Защитава само уникален индекс в базата.

**6. Circuit Breaker в паметта на процеса.**
Всеки worker прекъсва веригата сам за себе си. Пазете състоянието в общо хранилище.

**7. Липса на сверяване.**
Плащанията със статус `unknown` висят вечно, а потребителите пишат на поддръжката. Сверяването по график е задължително.

---

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

1. Всяко външно извикване има изрични `connectTimeout` и `timeout`, съобразени с броя на worker-ите.
2. Дългите извиквания са изнесени от HTTP заявката на потребителя в опашка.
3. Повтарят се само идемпотентни операции и само грешки при свързване, `429` и `5xx`, с експоненциална пауза и jitter.
4. Паричните операции получават ключ за идемпотентност преди извикването; ключът се пази с UNIQUE ограничение и се подава на доставчика.
5. Таймаутът прехвърля операцията в `unknown`, след което се стартира сверяване.
6. Webhook-овете се обработват идемпотентно: журнал на събитията с 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 е по-бърз от оперативната памет на процеса.
- Б) За да виждат всички worker-и и нодове едно и също състояние на веригата и да спрат да изпращат заявки едновременно, а не всеки поотделно след собствената си поредица от грешки.
- В) Laravel не може да пази данни в паметта на процеса.

<details>
<summary>Покажи правилния отговор</summary>

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