Junior: питання на співбесіді з теми «Інтеграції зі сторонніми API»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
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, і це треба обробити.
Довгі чи масові виклики - у черзі, а не в запиті користувача.
Тести не повинні ходити в реальні сторонні 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. Якщо провайдер змінив формат, фейки цього не помітять - тут допомагають контрактні тести чи моніторинг помилок у продакшені.
Сторонній 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.
Коли лише опитування: провайдер не має вебхуків; дані змінюються рідко і затримка неважлива; застосунок не має публічної адреси (внутрішня мережа).
Коли лише вебхуки (без звірки): втрата події некритична, або провайдер гарантує повторну доставку довго й надійно, а у вас є журнал отриманих подій.