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

Питання на співбесіді: HTTP-клієнт

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

6 питань

Через фасад Http - обгортку над Guzzle з простішим API.

use Illuminate\Support\Facades\Http;

$response = Http::withToken(config('services.github.token'))
    ->acceptJson()
    ->timeout(5)
    ->get('https://api.github.com/repos/laravel/framework', ['per_page' => 10]);

$stars = $response->json('stargazers_count');

Http::post('https://api.example.com/orders', ['sku' => 'A-1', 'qty' => 2]);   // тіло в JSON

Що повертає: об'єкт Response з методами:

  • json('key.nested'), collect(), object(), body() - дані;
  • status(), successful(), failed(), clientError(), serverError() - статус;
  • header('X-RateLimit-Remaining') - заголовки.

Корисне з першого дня:

  • дані POST за замовчуванням ідуть як JSON; для форми - asForm(), для файлів - attach();
  • timeout() - не чекати 30 секунд за замовчуванням;
  • ключі API - у config/services.php і .env, а не в коді.

Головна відмінність від «голого» Guzzle: на відповіді 4xx і 5xx клієнт не кидає винятків - статус перевіряють самі або викликають throw().

Докладніше в документації: Виконання запитів

Бо помилкова відповідь - теж відповідь: її можна прочитати, залогувати, обробити по-різному залежно від коду. Тому клієнт повертає Response і лишає рішення вам.

$response = Http::get($url);

if ($response->notFound()) {
    return null;                       // немає - нормальна ситуація
}

if ($response->serverError()) {
    Log::warning('API недоступне', ['status' => $response->status()]);
    throw new ServiceUnavailable;
}

Коли потрібен виняток - throw():

$data = Http::get($url)->throw()->json();     // RequestException на 4xx/5xx
Http::get($url)->throwIf(fn ($r) => $r->status() >= 500);
Http::get($url)->throwUnlessStatus(200);

Типова помилка новачка:

$user = Http::get($url)->json();   // на 500 тут буде тіло помилки, а не користувач
$user['name'];                     // і далі - дивна помилка в іншому місці

Окремий випадок - немає відповіді зовсім (таймаут, відмова з'єднання): тоді кидається ConnectionException завжди, бо читати нічого.

Правило: на кожен зовнішній виклик - або явна перевірка статусу, або throw().

Докладніше в документації: Обробка помилок

$response = Http::connectTimeout(3)
    ->timeout(10)
    ->retry([200, 500, 1000], when: fn (Throwable $e) => $e instanceof ConnectionException
        || ($e instanceof RequestException && $e->response->serverError()))
    ->get($url);

Таймаути:

  • connectTimeout() - скільки чекати з'єднання (за замовчуванням 10 с);
  • timeout() - скільки чекати відповідь (за замовчуванням 30 с).

30 секунд у веб-запиті - це заблокований воркер PHP. Коли сторонній сервіс зависає, такі запити швидко займають усі воркери, і падає весь сайт. Тому таймаут ставлять під реальну очікувану швидкість сервісу.

Повтори:

  • retry(3, 100) - три спроби з паузою 100 мс; масив задає паузи окремо (наростання);
  • третій аргумент when - що повторювати: збої з'єднання й 5xx так, 4xx - ні (помилка в запиті не виправиться повтором);
  • після вичерпаних спроб - RequestException, або остання відповідь з throw: false.

Обережно з не ідемпотентними запитами: повтор POST /payments після таймауту може створити другий платіж - перший міг пройти. Для таких - ключ ідемпотентності, якщо API його підтримує, або без автоматичних повторів.

У черзі повторами зручніше керувати рівнем завдання (tries, backoff, release() на 429), ніж всередині HTTP-клієнта.

Докладніше в документації: Повторні спроби

Через Http::fake() - запити не йдуть у мережу, а отримують задані відповіді.

it('imports exchange rates', function () {
    Http::preventStrayRequests();
    Http::fake([
        'api.rates.example/*' => Http::response(['usd' => 41.2, 'eur' => 44.9]),
    ]);

    app(RatesImporter::class)->import();

    expect(Rate::where('code', 'usd')->value('value'))->toBe(41.2);
    Http::assertSent(fn (Request $r) => $r->hasHeader('Authorization'));
});

Що підміняють:

  • конкретні адреси шаблонами з *;
  • послідовності: Http::sequence()->push(...)->pushStatus(500) - перша відповідь успішна, друга з помилкою;
  • збій з'єднання: Http::failedConnection();
  • логіку: замикання, що повертає відповідь залежно від запиту.

Перевірки запитів: assertSent, assertNotSent, assertSentCount, assertNothingSent.

preventStrayRequests() - будь-який непідмінений запит кидає виняток. Його ставлять у базовому TestCase чи Pest.php: тест, що випадково ходить у справжній API, стає повільним, нестабільним і може щось змінити в чужій системі.

Що обов'язково покрити: не лише успіх, а й 500, таймаут, 429, неочікуваний формат відповіді - саме ці гілки ламаються в продакшені.

Докладніше в документації: Підміна відповідей

Через Http::pool() або Http::batch() - запити відправляються одночасно, загальний час близький до найдовшого, а не до суми.

$responses = Http::pool(fn (Pool $pool) => [
    $pool->as('rates')->timeout(3)->get('https://api.example.com/rates'),
    $pool->as('news')->timeout(3)->get('https://api.example.com/news'),
    $pool->as('weather')->timeout(3)->get('https://api.example.com/weather'),
]);

$rates = $responses['rates']->json();

batch() додає колбеки:

Http::batch(fn (Batch $batch) => [
    $batch->get('https://api.example.com/a'),
    $batch->get('https://api.example.com/b'),
])->then(fn (Batch $batch, array $results) => /* усі успішні */)
  ->catch(fn (Batch $batch, $key, $response) => /* один з помилкою */)
  ->send();   // або ->defer() - виконати після відповіді користувачу

Що враховувати:

  • кожен запит у пулі налаштовують окремо - спільні заголовки не успадковуються від зовнішнього виклику;
  • аргумент concurrency обмежує одночасні запити, щоб не впертися в ліміт стороннього API;
  • результат з помилкою - це Response з 5xx або виняток з'єднання в масиві, перевіряють кожен;
  • pool не замінює чергу: якщо результат не потрібен відповіді, краще поставити завдання.

Для паралельного виконання довільного PHP-коду, а не HTTP, - Concurrency::run().

Докладніше в документації: Паралельні запити

Виклики Http::... розкидані по контролерах - це однакові заголовки, таймаути й обробка помилок у двадцяти місцях і жодної можливості підмінити сервіс у тестах окремо від HTTP.

1. Базова конфігурація в одному місці - макрос:

// AppServiceProvider::boot()
Http::macro('github', fn () => Http::baseUrl('https://api.github.com')
    ->withToken(config('services.github.token'))
    ->acceptJson()
    ->timeout(5)
    ->retry(2, 200, throw: false));

2. Клієнт-клас з операціями предметної області:

final class GitHubClient
{
    public function stars(string $repo): int
    {
        return Http::github()->get("/repos/{$repo}")->throw()->json('stargazers_count');
    }
}

Код застосунку викликає stars('laravel/framework') і не знає про URL, заголовки й формат відповіді.

3. Свої DTO замість сирих масивів - формат API змінився, і правка в одному місці.

4. Наскрізні речі - глобальні middleware клієнта (Http::globalRequestMiddleware) для User-Agent чи кореляційного ID і подія ConnectionFailed для журналу.

5. Тести на двох рівнях: клієнт - через Http::fake() з реальними прикладами відповідей; решта коду - через підміну самого GitHubClient (інтерфейс і фейкова реалізація в контейнері).

Для великих API з десятками ендпойнтів беруть пакет Saloon - він формалізує саме цю структуру.

Докладніше в документації: Макроси