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) для масових інтеграцій - щоб сотні джоб після збою провайдера не вдарили по ньому одночасно.
Послідовні запити складають свої затримки: три запити по 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.
Провайдери обмежують частоту запитів: «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 з чіткими типами на вході й виході.
Багато 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 - лише потрібні права.