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

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

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

5 питань

Метод 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