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