Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Інтеграції зі сторонніми API

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

14 питань

HTTP-клієнт Laravel (обгортка над Guzzle) робить запити до сторонніх API коротко й зручно:

use Illuminate\Support\Facades\Http;

$response = Http::withToken(config('services.nova_poshta.key'))
    ->acceptJson()
    ->connectTimeout(3)
    ->timeout(10)
    ->get('https://api.example.com/v1/parcels/123');

$status = $response->json('data.status');

Тайм-аути - обов'язкові для свідомого вибору:

  • connectTimeout - скільки чекати на встановлення з'єднання (за замовчуванням 10 секунд);
  • timeout - скільки чекати на всю відповідь (за замовчуванням 30 секунд).

Тридцять секунд очікування повільного API в обробнику запиту користувача - це тридцять секунд зайнятого процесу PHP і користувач, що дивиться на спінер. Для запитів у веб-обробнику тайм-аути варто ставити короткими.

Помилки - клієнт не кидає винятки сам. Відповідь 404 чи 500 - це звичайний об'єкт Response, і код спокійно продовжить роботу з порожніми даними. Перевіряйте явно:

if ($response->failed()) {            // 4xx або 5xx
    // ...
}

$response->successful();   // 2xx
$response->clientError();  // 4xx
$response->serverError();  // 5xx

$data = $response->throw()->json();   // кинути RequestException на 4xx/5xx

Два різні типи збою:

  • Illuminate\Http\Client\RequestException - сервер відповів з помилкою (throw());
  • Illuminate\Http\Client\ConnectionException - відповіді не було взагалі: тайм-аут, DNS, відмова в з'єднанні. Кидається завжди.

Що ще варто зробити відразу:

  • ключі й адреси - у config/services.php з .env, не в коді;
  • базову адресу й автентифікацію зібрати в одному місці (макрос або клас-клієнт), щоб не дублювати по проєкту;
  • логувати збої з контекстом (ендпойнт, статус, ідентифікатор запиту), але без токенів і персональних даних;
  • не довіряти формату відповіді: сторонній API може змінити поле - $response->json('data.status') поверне null, і це треба обробити.

Довгі чи масові виклики - у черзі, а не в запиті користувача.

Докладніше в документації: HTTP-клієнт: тайм-аути

Тести не повинні ходити в реальні сторонні API: вони стають повільними, нестабільними, залежать від мережі й можуть створювати справжні платежі чи відправляти справжні SMS.

Http::fake() підміняє відповіді:

use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;

it('stores the tracking status', function () {
    Http::fake([
        'api.example.com/v1/parcels/*' => Http::response(['data' => ['status' => 'delivered']]),
        'api.example.com/*' => Http::response(status: 404),
    ]);

    app(ParcelTracker::class)->refresh($parcel);

    expect($parcel->fresh()->status)->toBe('delivered');

    Http::assertSent(fn (Request $request) =>
        $request->url() === 'https://api.example.com/v1/parcels/123'
        && $request->hasHeader('Authorization')
    );
});

Сценарії збоїв - так само просто:

Http::fake(['api.example.com/*' => Http::response(status: 503)]);

// послідовність: спершу помилка, потім успіх - перевірка повторів
Http::fake([
    'api.example.com/*' => Http::sequence()
        ->pushStatus(503)
        ->push(['data' => ['status' => 'delivered']]),
]);

// мережевий збій (ConnectionException)
Http::fake(['api.example.com/*' => Http::failedConnection()]);

Http::preventStrayRequests() - будь-який запит без підготовленої фейкової відповіді кидає виняток. Найкраще ввімкнути глобально в базовому класі тестів чи Pest.php: тоді жоден тест випадково не піде в мережу, навіть якщо хтось забув Http::fake.

Корисні перевірки:

  • Http::assertSentCount(2) - скільки запитів зроблено;
  • Http::assertNothingSent() - запитів не було (наприклад, через кеш);
  • Http::assertSentInOrder([...]) - порядок.

Реалістичні відповіді. Найкраще зберігати справжні (знеособлені) відповіді API у файлах-фікстурах і віддавати їх з Http::response(json_decode(file_get_contents(...))). Тести тоді перевіряють розбір саме того формату, що надсилає провайдер.

Пісочниці провайдерів (тестові ключі Stripe, LiqPay, Monobank) - для ручної перевірки й рідкісних інтеграційних тестів, які запускаються окремо від основного набору, а не для кожного прогону CI.

Обмеження фейків: вони перевіряють ваш код проти вашого уявлення про API. Якщо провайдер змінив формат, фейки цього не помітять - тут допомагають контрактні тести чи моніторинг помилок у продакшені.

Докладніше в документації: HTTP-клієнт: тестування

Сторонній API - найменш контрольована частина системи: він буває повільним, недоступним, обмежує частоту запитів. Якщо викликати його прямо в обробнику запиту користувача, всі його проблеми стають вашими.

Що відбувається без черги:

public function store(Request $request)
{
    $order = Order::create($request->validated());

    Http::post('https://crm.example.com/api/deals', [...]);   // 8 секунд...
    Http::post('https://sms.example.com/send', [...]);        // ...і впав з 503

    return redirect()->route('orders.show', $order);
}
  • користувач чекає, поки відповідять усі сторонні сервіси;
  • падіння CRM ламає оформлення замовлення, хоча замовлення вже створене;
  • процеси PHP-FPM зайняті очікуванням мережі - під навантаженням вони закінчуються, і сайт гальмує повністю;
  • повторити невдалий виклик ніхто не спробує.

З чергою:

public function store(Request $request)
{
    $order = Order::create($request->validated());

    SyncOrderToCrm::dispatch($order)->afterCommit();
    SendOrderSms::dispatch($order)->afterCommit();

    return redirect()->route('orders.show', $order);
}

Що дає черга:

  • швидка відповідь користувачу незалежно від сторонніх сервісів;
  • повторні спроби з затримкою ($tries, backoff()) - тимчасові збої вирішуються самі;
  • ізоляція: окрема черга для інтеграцій з власними воркерами - повільний провайдер не затримує листи чи генерацію звітів;
  • обмеження частоти через middleware джоби (RateLimited, ThrottlesExceptions) - під ліміти провайдера;
  • видимість: невдалі джоби в failed_jobs чи Horizon, їх можна повторити вручну.

afterCommit() - джоба потрапить у чергу лише після коміту транзакції. Інакше воркер може взяти її раніше, ніж замовлення з'явиться в базі.

Коли синхронний виклик виправданий: результат потрібен одразу для відповіді (перевірка адреси доставки, розрахунок вартості, ініціалізація оплати з перенаправленням). Тоді - короткий тайм-аут і зрозуміле повідомлення користувачу при збої.

Джоба має бути ідемпотентною: повтор не повинен створити в CRM дві угоди. Зберігайте зовнішній ідентифікатор після першого успішного виклику і перевіряйте його перед створенням.

Докладніше в документації: Черги

Базове правило: ключі не потрапляють у код і в Git. Вони живуть у змінних оточення, а код звертається до них через конфігурацію.

# .env - у .gitignore
STRIPE_SECRET=sk_live_...
NOVA_POSHTA_API_KEY=...
// config/services.php
'nova_poshta' => [
    'key' => env('NOVA_POSHTA_API_KEY'),
    'url' => env('NOVA_POSHTA_URL', 'https://api.novaposhta.ua/v2.0/json/'),
],

// у коді
Http::withToken(config('services.nova_poshta.key'));

Чому config(), а не env() у коді: після php artisan config:cache файл .env більше не читається, і env() поза конфігураційними файлами поверне null.

Що ще важливо:

  • .env.example містить назви змінних без значень - новий розробник бачить, що потрібно налаштувати;
  • різні ключі для середовищ: тестові ключі пісочниці на локальній машині й staging, бойові - лише в продакшені;
  • мінімальні права ключа: якщо провайдер дозволяє обмежити ключ (лише читання, певні IP, певні операції), - обмежувати;
  • ротація: план заміни ключа без простою (новий ключ → деплой → відкликання старого) і обов'язкова заміна, якщо ключ міг витекти;
  • не логувати секрети: заголовки Authorization, тіла з ключами. У PHP 8.2+ атрибут #[\SensitiveParameter] приховує значення параметра в стеку помилки - Laravel використовує його, наприклад, у withToken().

Зашифрований .env у репозиторії - вбудований механізм Laravel:

php artisan env:encrypt --env=production      # створює .env.production.encrypted
php artisan env:decrypt --env=production --key=...

Зашифрований файл можна комітити, а ключ розшифрування передається лише в CI чи на сервер. Зручно для невеликих команд без окремого сховища секретів.

Для більших систем - менеджери секретів (Vault, AWS Secrets Manager, Doppler, секрети платформи деплою), де є аудит доступу й централізована ротація.

Токени користувачів до сторонніх сервісів (OAuth-доступ до їхніх Google чи GitHub) - це вже дані, а не конфігурація: зберігаються в базі зашифрованими (каст encrypted в Eloquent).

Ключ, що потрапив у Git, вважається скомпрометованим, навіть якщо коміт видалили: історія й форки лишаються. Тільки відкликання.

Докладніше в документації: Шифрування файлів оточення

Задача: дізнатися, що в сторонньому сервісі щось змінилося - оплата пройшла, посилка доставлена, клієнт оновився в CRM.

Опитування (polling) - ваш застосунок періодично питає API:

Schedule::job(new RefreshParcelStatuses)->everyFifteenMinutes();
  • плюси: просто, працює з будь-яким API, ви контролюєте частоту й момент; не потрібна публічна адреса;
  • мінуси: затримка до інтервалу опитування; більшість запитів - марні («нічого не змінилося»); з'їдає ліміти частоти провайдера; погано масштабується на тисячі об'єктів.

Вебхуки - провайдер сам надсилає запит на ваш URL, коли подія сталася:

  • плюси: майже миттєво; немає марних запитів; масштабується;
  • мінуси: потрібна публічна HTTPS-адреса; треба перевіряти підпис; провайдер може не доставити (збій у нього, ваш простій) - подію буде втрачено або доставлено пізніше; дублікати й зміна порядку.

На практиці найнадійніше - поєднання:

  • вебхук - як сигнал для швидкої реакції;
  • періодичне опитування - як страховка (звірка): раз на годину чи добу перевірити об'єкти в «незавершених» станах (оплата «очікується» понад годину) і підтягнути їхній актуальний статус.

Так система не залежить від того, що кожен вебхук дійде.

Правила обробки вхідних вебхуків:

  • перевірити підпис, відповісти 200 швидко, а обробку - в чергу;
  • ідемпотентність за id події: дублікат не повинен обробитися двічі;
  • не покладатися на порядок подій - порівнювати з поточним станом чи запитувати свіжі дані з API;
  • для локальної розробки - тунель (Expose, ngrok) чи CLI провайдера, що пересилає події на localhost.

Коли лише опитування: провайдер не має вебхуків; дані змінюються рідко і затримка неважлива; застосунок не має публічної адреси (внутрішня мережа).

Коли лише вебхуки (без звірки): втрата події некритична, або провайдер гарантує повторну доставку довго й надійно, а у вас є журнал отриманих подій.

Докладніше в документації: Про вебхуки GitHub

Метод retry повторює запит при помилці:

Http::retry(3, 200)->get($url);                       // 3 спроби, 200 мс між ними
Http::retry([100, 500, 2000])->get($url);             // затримки по черзі
Http::retry(4, fn (int $attempt) => 2 ** $attempt * 100)->get($url);   // експоненційно

Сигнатура: retry(array|int $times, Closure|int $sleepMilliseconds = 0, ?callable $when = null, bool $throw = true).

Третій аргумент - коли повторювати. За замовчуванням повтор іде при будь-якій помилці - і клієнтській (4xx), і серверній. Це рідко правильно:

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;

Http::retry(3, 300, function (Throwable $exception, PendingRequest $request) {
    if ($exception instanceof ConnectionException) {
        return true;                                   // мережа, тайм-аут
    }

    return $exception instanceof RequestException
        && in_array($exception->response->status(), [429, 500, 502, 503, 504], true);
})->get($url);

Що повторювати: мережеві збої, тайм-аути, 502/503/504, 429 (краще з урахуванням Retry-After).

Що НЕ повторювати:

  • 400, 404, 422 - повтор дасть ту саму відповідь;
  • 401/403 - хіба що після оновлення токена: колбек може змінити запит ($request->withToken($newToken)) і повернути true;
  • неідемпотентні операції (POST на створення платежу, відправка SMS) без ключа ідемпотентності: таймаут не означає, що запит не дійшов - повтор може списати гроші двічі.

throw: false - після всіх спроб повернути останню відповідь замість винятку. Але ConnectionException кидається все одно, якщо всі спроби впали через з'єднання.

Повтори в запиті користувача vs у черзі:

  • синхронно - лише короткі повтори (1-2 спроби, сотні мілісекунд): користувач чекає, а процес PHP зайнятий;
  • у джобі - довгі затримки краще віддати черзі ($tries, backoff()): джоба звільняє воркер між спробами, а не спить у usleep.

Не множити повтори. Http::retry(3) всередині джоби з $tries = 5 - до 15 запитів на одну операцію, а з повторами на рівні проксі чи SDK - ще більше. Кожен рівень, що повторює, - вирішується свідомо.

Випадковий розкид затримки (jitter) для масових інтеграцій - щоб сотні джоб після збою провайдера не вдарили по ньому одночасно.

Докладніше в документації: HTTP-клієнт: повторні спроби

Послідовні запити складають свої затримки: три запити по 400 мс - 1,2 секунди. Якщо вони незалежні, їх можна виконати одночасно - і чекати лише найповільніший.

use Illuminate\Http\Client\Pool;
use Illuminate\Support\Facades\Http;

$responses = Http::pool(fn (Pool $pool) => [
    $pool->as('rates')->get('https://api.example.com/rates'),
    $pool->as('stock')->withToken($token)->get('https://warehouse.example.com/stock/42'),
    $pool->as('delivery')->timeout(5)->get('https://delivery.example.com/estimate', ['city' => 'Київ']),
]);

$rates = $responses['rates']->json();

Особливості пулу:

  • заголовки й налаштування - на кожному запиті в пулі: Http::withHeaders(...)->pool(...) не застосується до запитів усередині;
  • as() дає доступ до відповідей за назвою, без нього - за індексом у порядку додавання;
  • помилки не кидаються: невдалий запит повертає відповідь з кодом помилки, а збій з'єднання - об'єкт винятку на місці відповіді. Перевіряйте кожен результат: $response instanceof Response && $response->successful().

Обмеження паралельності - якщо запитів багато:

$responses = Http::pool(fn (Pool $pool) => collect($skus)
    ->map(fn ($sku) => $pool->as($sku)->get("https://api.example.com/products/{$sku}"))
    ->all(), concurrency: 5);

Без обмеження сотня запитів піде одночасно - і провайдер відповість 429 або заблокує ключ.

Http::batch() - схожий механізм з колбеками before, progress, then, catch, finally - зручно для відстеження прогресу й обробки окремих збоїв.

Коли пул доречний:

  • кілька незалежних джерел для однієї сторінки чи розрахунку (курси, залишки, доставка);
  • масове оновлення даних у джобі - з concurrency під ліміт провайдера.

Чого пул не вирішує:

  • сумарне навантаження: паралельність прискорює ваш процес, але не зменшує кількість запитів до провайдера;
  • ліміти частоти - для тисяч запитів потрібна черга з RateLimited, а не один гігантський пул;
  • довгі операції в запиті користувача - навіть паралельно, три запити по 5 секунд - це 5 секунд очікування. Такі речі - у фоні.

Альтернатива поза HTTP-клієнтом - Concurrency::run() для паралельного виконання довільних замикань (окремі процеси), коли треба розпаралелити не лише HTTP.

Докладніше в документації: HTTP-клієнт: паралельні запити

Провайдери обмежують частоту запитів: «60 на хвилину», «10 на секунду на ключ». Десять воркерів, що паралельно синхронізують тисячі записів, легко перевищать ліміт - і отримають 429 або тимчасове блокування ключа.

1. Обмежувач частоти для джоб - RateLimiter::for() + middleware RateLimited:

// AppServiceProvider::boot()
RateLimiter::for('crm', fn (object $job) => Limit::perMinute(50));
use Illuminate\Queue\Middleware\RateLimited;

final class SyncContactToCrm implements ShouldQueue
{
    public function middleware(): array
    {
        return [new RateLimited('crm')];
    }

    public function retryUntil(): DateTime
    {
        return now()->plus(hours: 6);
    }
}

Джоба, що перевищила ліміт, повертається в чергу з затримкою. Важливо: повернення збільшує лічильник спроб - тому замість малого $tries використовують retryUntil() (обмеження за часом).

Ліміт спільний для всіх воркерів і серверів, бо зберігається в кеші (для Redis є RateLimitedWithRedis).

2. Реакція на 429 від провайдера. Навіть з власним обмежувачем провайдер може відповісти 429 (інші клієнти того самого ключа, інша методика підрахунку). Поважайте Retry-After:

$response = Http::crm()->post('/contacts', $payload);

if ($response->status() === 429) {
    $this->release((int) $response->header('Retry-After') ?: 60);
    return;
}

3. ThrottlesExceptions - якщо провайдер почав масово падати, перестати його смикати:

return [(new ThrottlesExceptions(10, 5 * 60))->by('crm-api')->backoff(1)];

Після 10 винятків поспіль джоби з тим самим ключем чекають 5 хвилин. by() об'єднує різні джоби, що ходять до одного провайдера, у спільний «кошик».

4. Окрема черга з обмеженою кількістю воркерів - найпростіший грубий обмежувач: черга crm з двома процесами у Horizon фізично не зробить більше двох запитів одночасно.

5. Зменшити кількість запитів: пакетні ендпойнти провайдера (оновити 100 записів одним запитом), кешування довідників, ShouldBeUnique - щоб не ставити в чергу синхронізацію того самого запису десять разів поспіль.

Моніторинг: кількість 429 і довжина черги інтеграцій - перші сигнали, що ліміт треба переглянути (чи купувати вищий тариф API).

Докладніше в документації: Черги: обмеження частоти

Коли виклики до одного API розкидані по контролерах і джобах (Http::withToken(...)->get('https://...') у двадцяти місцях), будь-яка зміна - нова версія API, інший заголовок, логування - перетворюється на пошук по всьому проєкту. Налаштування треба зібрати в одному місці.

Рівень 1 - макрос з базовими налаштуваннями:

// AppServiceProvider::boot()
Http::macro('novaPoshta', fn () => Http::baseUrl(config('services.nova_poshta.url'))
    ->acceptJson()
    ->connectTimeout(3)
    ->timeout(10)
    ->retry(2, 300, fn ($e) => $e instanceof ConnectionException));

// використання
$response = Http::novaPoshta()->post('/', $payload);

Добре для невеликої кількості викликів: спільні адреса, тайм-аути, автентифікація.

Рівень 2 - клас-клієнт з методами предметної області:

final class NovaPoshtaClient
{
    public function __construct(private readonly string $apiKey) {}

    public function trackParcel(string $number): ParcelStatus
    {
        $response = Http::novaPoshta()
            ->post('/', [
                'apiKey' => $this->apiKey,
                'modelName' => 'TrackingDocument',
                'calledMethod' => 'getStatusDocuments',
                'methodProperties' => ['Documents' => [['DocumentNumber' => $number]]],
            ])
            ->throw();

        return ParcelStatus::fromApi($response->json('data.0'));
    }
}

Реєстрація в контейнері ($this->app->singleton(...) з ключем з конфігурації) і впровадження в конструктори.

Що дає клас:

  • решта коду не знає про HTTP: він викликає trackParcel() і отримує DTO, а не масив з дивними ключами провайдера;
  • один місце для перетворення зовнішнього формату у ваші типи - зміни провайдера не розходяться по проєкту;
  • легко підмінити в тестах - фейковою реалізацією інтерфейсу або Http::fake();
  • місце для кешування, логування, обробки специфічних помилок провайдера.

Рівень 3 - SDK. Якщо провайдер має офіційний PHP SDK, часто розумно ним скористатися - але через власний клас-обгортку, щоб SDK не проник у весь код. Для власних SDK до великих API - бібліотека Saloon з конекторами, запитами, DTO й тестовими моками.

Правило: кількість шарів - за складністю інтеграції. Один виклик - макросу досить; ключова інтеграція з десятками методів (платежі, CRM, склад) - клас-клієнт або SDK з чіткими типами на вході й виході.

Докладніше в документації: HTTP-клієнт: макроси

Багато API вимагають не статичний ключ, а короткоживучий токен доступу, який треба періодично отримувати й оновлювати.

Client Credentials - сервер звертається до API від імені свого застосунку, без участі користувача (інтеграції між системами):

final class WarehouseToken
{
    public function get(): string
    {
        return Cache::remember('warehouse:access_token', now()->plus(minutes: 50), function () {
            $response = Http::asForm()
                ->post(config('services.warehouse.token_url'), [
                    'grant_type' => 'client_credentials',
                    'client_id' => config('services.warehouse.client_id'),
                    'client_secret' => config('services.warehouse.client_secret'),
                    'scope' => 'stock:read',
                ])
                ->throw();

            return $response->json('access_token');
        });
    }
}

Ключові моменти:

  • кешувати токен на термін трохи менший за expires_in (запас на годинники й тривалі запити). Запит нового токена на кожен виклик API - зайві затримки і можливе блокування за частотою;
  • конкурентне оновлення: коли токен закінчився, десяток воркерів одночасно підуть за новим. Захист - блокування (Cache::lock('warehouse:token')) чи Cache::flexible для фонового оновлення;
  • повтор при 401: токен могли відкликати раніше терміну - скинути кеш, отримати новий і повторити запит один раз:
Http::withToken($token->get())
    ->retry(2, 0, function ($exception, PendingRequest $request) use ($token) {
        if (! $exception instanceof RequestException || $exception->response->status() !== 401) {
            return false;
        }
        $request->withToken($token->refresh());
        return true;
    })
    ->get($url);

Токени від імені користувача (Authorization Code: доступ до Google Calendar користувача, його GitHub):

  • зберігати access token і refresh token у базі, зашифрованими (каст encrypted);
  • оновлювати access token через refresh token, коли закінчився термін, і зберігати новий refresh token, якщо провайдер його видав (багато провайдерів ротують refresh token при кожному використанні - старий перестає працювати);
  • обробляти відкликання доступу (користувач відключив застосунок): invalid_grant - позначити інтеграцію неактивною й попросити підключити знову;
  • у Laravel вхід через провайдерів і отримання токенів - Socialite.

Секрети клієнта (client_secret) - у .env/менеджері секретів, ніколи у фронтенді. Мінімальні scope - лише потрібні права.

Докладніше в документації: OAuth 2.0: Client Credentials

Коли сторонній сервіс лежить, повтори й тайм-аути не допомагають, а шкодять: кожен запит чекає повний тайм-аут, воркери й процеси PHP зайняті очікуванням, черга росте, а сервіс, що намагається піднятися, отримує лавину запитів.

Circuit Breaker («запобіжник») відстежує помилки й після порогу перестає викликати сервіс на певний час:

Closed (норма)  -- N помилок поспіль -->  Open (виклики не йдуть, одразу помилка)
     ^                                          |
     |                                     минув час
     |                                          v
     +----- успіх ----  Half-open (пропустити пробний запит)  ---- помилка ---> Open

Що це дає:

  • швидка відмова замість 30-секундного очікування: користувач одразу бачить запасний варіант, воркери вільні;
  • сервісу дають відновитися без лавини повторів;
  • запасна поведінка: закешовані дані, відкладена обробка, повідомлення «сервіс тимчасово недоступний».

У Laravel для фонових задач роль запобіжника виконує middleware ThrottlesExceptions:

public function middleware(): array
{
    return [
        (new ThrottlesExceptions(maxAttempts: 10, decaySeconds: 5 * 60))
            ->by('payment-provider')                                  // спільно для всіх джоб провайдера
            ->when(fn (Throwable $e) => $e instanceof ConnectionException
                || ($e instanceof RequestException && $e->response->serverError())),
    ];
}

Після 10 помилок джоби з цим ключем не виконуються 5 хвилин, а повертаються в чергу. З retryUntil() вони не губляться.

Для синхронних викликів простий запобіжник на кеші:

final class CircuitBreaker
{
    public function __construct(private string $service, private int $threshold = 5, private int $cooldown = 60) {}

    public function call(callable $callback, callable $fallback): mixed
    {
        if (Cache::has("circuit:{$this->service}:open")) {
            return $fallback();
        }

        try {
            $result = $callback();
            Cache::forget("circuit:{$this->service}:failures");
            return $result;
        } catch (ConnectionException|RequestException $e) {
            if (Cache::increment("circuit:{$this->service}:failures") >= $this->threshold) {
                Cache::put("circuit:{$this->service}:open", true, $this->cooldown);
            }
            return $fallback();
        }
    }
}

Що важливо:

  • рахувати лише «системні» помилки (мережа, 5xx, тайм-аути), а не 4xx від ваших же некоректних запитів;
  • стан спільний для всіх процесів - у Redis, а не в пам'яті одного воркера;
  • сповіщення: перехід у стан Open - подія для моніторингу й чергового;
  • короткі тайм-аути - запобіжник не замінює їх, а доповнює.

Готові рішення є й у вигляді пакетів, але для більшості Laravel-застосунків ThrottlesExceptions у черзі + короткі тайм-аути + кеш як запасний варіант покривають потреби.

Докладніше в документації: Martin Fowler: Circuit Breaker

У інтеграціях дублікати неминучі: провайдер повторює вебхук, бо не дочекався відповіді; джоба повторюється після тайм-ауту, хоча перша спроба насправді завершилася; користувач двічі натискає «Синхронізувати». Обробка має давати той самий результат незалежно від кількості повторів.

1. Унікальність на вході - журнал оброблених подій:

Schema::create('processed_events', function (Blueprint $table) {
    $table->string('provider');
    $table->string('event_id');
    $table->timestamp('processed_at');
    $table->primary(['provider', 'event_id']);
});
public function handle(): void
{
    DB::transaction(function () {
        $inserted = DB::table('processed_events')->insertOrIgnore([
            'provider' => 'stripe',
            'event_id' => $this->event['id'],
            'processed_at' => now(),
        ]);

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

        $this->applyPayment($this->event);
    });
}

Унікальний ключ у базі - єдиний надійний захист від гонитви: перевірка «чи є запис» і вставка окремими запитами пропускають паралельні дублікати.

2. Ідемпотентні операції за природою:

  • updateOrCreate/upsert за зовнішнім ідентифікатором (external_id) замість create;
  • «встановити статус paid» замість «збільшити суму на X»;
  • переходи станів з перевіркою: «оплачене» не можна оплатити вдруге.

3. Унікальні джоби - щоб не ставити в чергу однакову роботу:

#[UniqueFor(600)]
final class SyncCustomer implements ShouldQueue, ShouldBeUnique
{
    public function uniqueId(): string
    {
        return (string) $this->customerId;
    }
}

Поки джоба для цього клієнта в черзі, нові такі ж відкидаються. Потребує кешу з атомарними блокуваннями і спільного кешу для всіх серверів.

4. Вихідні операції з побічними ефектами (створення платежу, відправка SMS) - ключ ідемпотентності в запиті до провайдера, стабільний для повторів тієї самої операції (Idempotency-Key: order-42-capture). Тоді повтор джоби після тайм-ауту не створить другий платіж.

5. Звірка (reconciliation) - останній рубіж: періодичне порівняння стану у вас і в провайдера (оплати за добу, залишки на складі), автоматичне виправлення розбіжностей і звіт про них. Ловить те, що загубилося попри всі механізми.

Типова помилка: покладатися на ShouldBeUnique як на захист від повторної обробки. Він не дає поставити дублікат у чергу, але не захищає від повторного виконання вже взятої джоби після збою воркера - для цього потрібні ідемпотентні операції й унікальні ключі в базі.

Докладніше в документації: Черги: унікальні джоби

Saloon - бібліотека для PHP (з інтеграцією в Laravel), що структурує роботу з API у класи: конектор описує API загалом, запит - один ендпойнт.

use Saloon\Http\Connector;
use Saloon\Traits\Plugins\AcceptsJson;

final class CrmConnector extends Connector
{
    use AcceptsJson;

    public ?int $tries = 3;
    public ?int $retryInterval = 500;
    public ?bool $useExponentialBackoff = true;

    public function __construct(private readonly string $token) {}

    public function resolveBaseUrl(): string
    {
        return 'https://crm.example.com/api/v2';
    }

    protected function defaultAuth(): TokenAuthenticator
    {
        return new TokenAuthenticator($this->token);
    }
}
use Saloon\Enums\Method;
use Saloon\Http\Request;
use Saloon\Http\Response;

final class GetDeal extends Request
{
    protected Method $method = Method::GET;

    public function __construct(private readonly int $id) {}

    public function resolveEndpoint(): string
    {
        return "/deals/{$this->id}";
    }

    public function createDtoFromResponse(Response $response): Deal
    {
        return Deal::fromArray($response->json('data'));
    }
}

$deal = $connector->send(new GetDeal(42))->dto();   // Deal, а не масив

Що дає порівняно з Http:: у класі-клієнті:

  • один запит - один клас: ендпойнт, метод, тіло, заголовки, перетворення відповіді в DTO живуть разом. Великий API (десятки ендпойнтів) не перетворюється на клас-клієнт на тисячу рядків;
  • DTO на виході (createDtoFromResponse + dto()) - решта застосунку не бачить формату провайдера;
  • повтори, автентифікація, пагінація, OAuth2 - готові механізми на рівні конектора;
  • тести: MockClient з відповідями для конкретних класів запитів і фікстури - записані справжні відповіді, що відтворюються в тестах;
  • middleware на рівні конектора - логування, метрики, ідентифікатори кореляції в одному місці.

Як не перестаратися:

  • для двох-трьох викликів Saloon - зайва церемонія; макрос чи невеликий клас-клієнт простіші;
  • DTO мають відображати потреби вашого коду, а не повністю копіювати відповідь провайдера: мапінг лише потрібних полів, перетворення типів (дати, гроші в мінімальних одиницях, енуми статусів) і явна обробка відсутніх полів;
  • не тягнути SDK у предметну область: сервіси застосунку залежать від вашого інтерфейсу (CrmGateway), а Saloon - деталь його реалізації. Тоді зміна провайдера чи бібліотеки не зачіпає бізнес-логіку.

Коли Saloon особливо доречний: ключова інтеграція з великою кількістю ендпойнтів, кілька інтеграцій з однаковими підходами в команді, публікація власного SDK для свого API.

Докладніше в документації: Saloon: конектори

Коли «не приходять SMS» чи «замовлення не потрапили в CRM», перше питання - що саме відбувалося між вашим застосунком і провайдером. Без записів про вихідні запити відповісти неможливо.

Події HTTP-клієнта - точка для централізованого логування без зміни коду інтеграцій:

use Illuminate\Http\Client\Events\ConnectionFailed;
use Illuminate\Http\Client\Events\ResponseReceived;

Event::listen(function (ResponseReceived $event) {
    Log::channel('integrations')->info('http.out', [
        'host' => parse_url($event->request->url(), PHP_URL_HOST),
        'method' => $event->request->method(),
        'path' => parse_url($event->request->url(), PHP_URL_PATH),
        'status' => $event->response->status(),
        'duration_ms' => round(($event->response->transferStats?->getTransferTime() ?? 0) * 1000),
    ]);
});

Event::listen(fn (ConnectionFailed $event) => Log::channel('integrations')->warning('http.out.failed', [
    'url' => $event->request->url(),
]));

Також є RequestSending перед відправкою і глобальні middleware клієнта (Http::globalRequestMiddleware, globalResponseMiddleware) - наприклад, щоб додати заголовок кореляції до всіх вихідних запитів.

Що варто записувати:

  • провайдер, ендпойнт (без параметрів з персональними даними), метод, статус, тривалість, номер спроби;
  • ідентифікатор запиту провайдера з заголовка відповіді (X-Request-Id, CF-Ray) - саме його попросить підтримка провайдера;
  • ваш ідентифікатор кореляції - щоб зв'язати вихідний запит з HTTP-запитом користувача чи джобою, що його породили (Context::add('trace_id', ...) в Laravel додає його в усі журнали);
  • для збоїв - фрагмент тіла відповіді з помилкою.

Чого НЕ записувати: токени й ключі (Authorization), паролі, номери карток, повні тіла з персональними даними. Маскування - в одному місці, у слухачі подій.

Метрики й сповіщення важливіші за журнали:

  • частка помилок і латентність за провайдером (p95);
  • кількість 429 - наближення до лімітів;
  • довжина черги інтеграцій і кількість невдалих джоб;
  • сповіщення, коли частка помилок провайдера перевищує поріг.

Інструменти в Laravel-екосистемі: Pulse (картки повільних вихідних запитів), Telescope (на staging - детально кожен запит), Nightwatch, OpenTelemetry для розподіленого трасування.

Журнал бізнес-операцій інтеграції (таблиця «синхронізації»: що відправлено, коли, результат, зовнішній ID) - окремо від технічних журналів. Його показують підтримці й адміністраторам і використовують для звірки.

Пісочниці провайдерів у staging - щоб збої інтеграцій ловилися до продакшену, а моніторинг працював і там.

Докладніше в документації: HTTP-клієнт: події