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

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 у черзі + короткі тайм-аути + кеш як запасний варіант покривають потреби.

Докладніше в документації: 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-клієнт: події