HardПрактика8 min

HTTP-клиент

Выполнение запросов, повторные попытки, параллельные запросы, макросы, тестирование HTTP-клиента, фейковые ответы в Laravel 11

Laravel предоставляет выразительный API поверх Guzzle HTTP-клиента для выполнения исходящих HTTP-запросов. HTTP-клиент включает удобные инструменты для тестирования, retry-логики и параллельных запросов.

Выполнение запросов

Основные методы

use Illuminate\Support\Facades\Http;

// GET request
$response = Http::get('https://api.example.com/users');

// GET with query parameters
$response = Http::get('https://api.example.com/users', [
    'page' => 1,
    'per_page' => 25,
    'sort' => 'created_at',
]);

// POST request
$response = Http::post('https://api.example.com/users', [
    'name' => 'John Doe',
    'email' => '[email protected]',
]);

// PUT request
$response = Http::put('https://api.example.com/users/1', [
    'name' => 'John Updated',
]);

// PATCH request
$response = Http::patch('https://api.example.com/users/1', [
    'status' => 'active',
]);

// DELETE request
$response = Http::delete('https://api.example.com/users/1');

Работа с ответом

$response = Http::get('https://api.example.com/users');

// Response body
$body = $response->body();           // Raw string
$json = $response->json();           // Decoded JSON as array
$json = $response->json('data.0');   // Dot notation access
$object = $response->object();       // Decoded JSON as object
$collection = $response->collect();  // As Collection
$collection = $response->collect('data'); // Nested as Collection

// Status checks
$response->status();                // 200
$response->ok();                    // true (200)
$response->successful();            // true (200-299)
$response->redirect();              // true (300-399)
$response->clientError();           // true (400-499)
$response->serverError();           // true (500-599)
$response->failed();                // true (client or server error)

// Specific status checks
$response->created();               // 201
$response->accepted();              // 202
$response->noContent();             // 204
$response->notFound();              // 404
$response->unauthorized();          // 401
$response->forbidden();             // 403
$response->unprocessable();         // 422
$response->tooManyRequests();       // 429

// Headers
$response->header('Content-Type');
$response->headers();               // All headers

Обработка ошибок

// Throw exception on client/server error
$response = Http::get('https://api.example.com/users')->throw();

// Throw with custom handling
$response = Http::get('https://api.example.com/users')
    ->throw(function ($response, $e) {
        Log::error('API call failed', [
            'status' => $response->status(),
            'body' => $response->body(),
        ]);
    });

// Throw if specific condition
$response->throwIf($response->json('error') !== null);
$response->throwUnless($response->json('success') === true);

// Throw if failed (4xx or 5xx)
$response->throwIfStatus(429);
$response->throwUnlessStatus(200);

Настройка запроса

Заголовки

$response = Http::withHeaders([
    'X-Api-Key' => config('services.external.api_key'),
    'Accept' => 'application/json',
    'X-Request-Id' => (string) Str::uuid(),
])->get('https://api.example.com/data');

// Content type
$response = Http::contentType('application/xml')
    ->accept('application/json')
    ->post('https://api.example.com/data', $xmlContent);

Аутентификация

// Bearer token
$response = Http::withToken($apiToken)->get('https://api.example.com/me');

// Basic auth
$response = Http::withBasicAuth('username', 'password')
    ->get('https://api.example.com/data');

// Digest auth
$response = Http::withDigestAuth('username', 'password')
    ->get('https://api.example.com/data');

Timeout

// Connection timeout + request timeout
$response = Http::timeout(30)           // Request timeout in seconds
    ->connectTimeout(10)                 // Connection timeout in seconds
    ->get('https://api.example.com/data');

Отправка форм и файлов

// Form URL encoded
$response = Http::asForm()->post('https://api.example.com/login', [
    'email' => '[email protected]',
    'password' => 'secret',
]);

// Multipart / file upload
$response = Http::attach(
    'avatar',
    file_get_contents('/path/to/photo.jpg'),
    'avatar.jpg'
)->post('https://api.example.com/profile', [
    'name' => 'John Doe',
]);

// Multiple files
$response = Http::attach('document', $fileContents, 'report.pdf')
    ->attach('photo', $photoContents, 'me.jpg')
    ->post('https://api.example.com/upload');

Отправка сырого тела

// Raw body
$response = Http::withBody($xmlString, 'application/xml')
    ->post('https://api.example.com/xml-endpoint');

Повторные попытки (Retries)

// Retry 3 times with 100ms delay
$response = Http::retry(3, 100)->get('https://api.example.com/data');

// Retry with exponential backoff
$response = Http::retry(3, function (int $attempt, \Exception $exception) {
    return $attempt * 200; // 200ms, 400ms, 600ms
})->get('https://api.example.com/data');

// Retry only on specific conditions
$response = Http::retry(3, 100, function (\Exception $exception, $response) {
    // Retry only on server errors or rate limiting
    return $exception instanceof ConnectionException
        || ($response && $response->status() === 429);
})->get('https://api.example.com/data');

// Retry with throw on final failure
$response = Http::retry(3, 100, throw: true)
    ->get('https://api.example.com/data');

::alert{type="info"} retry(): Первый аргумент - количество попыток (3 = 1 основной + 2 повторных). Второй - задержка в миллисекундах (или callback). Третий - условие для повтора. throw: true выбрасывает исключение после исчерпания попыток. ::

Параллельные запросы (Concurrent Requests)

use Illuminate\Http\Client\Pool;

// Execute multiple requests concurrently
$responses = Http::pool(fn (Pool $pool) => [
    $pool->get('https://api.example.com/users'),
    $pool->get('https://api.example.com/orders'),
    $pool->get('https://api.example.com/products'),
]);

// Access responses by index
$users = $responses[0]->json();
$orders = $responses[1]->json();
$products = $responses[2]->json();

// Named requests
$responses = Http::pool(fn (Pool $pool) => [
    $pool->as('users')->get('https://api.example.com/users'),
    $pool->as('orders')->get('https://api.example.com/orders'),
    $pool->as('products')->get('https://api.example.com/products'),
]);

$users = $responses['users']->json();
$orders = $responses['orders']->json();
$products = $responses['products']->json();

// With auth and options per request
$responses = Http::pool(fn (Pool $pool) => [
    $pool->as('github')
        ->withToken(config('services.github.token'))
        ->get('https://api.github.com/user'),

    $pool->as('stripe')
        ->withToken(config('services.stripe.secret'))
        ->get('https://api.stripe.com/v1/customers'),
]);

Макросы

Макросы позволяют определить предустановленные конфигурации HTTP-клиента:

// In AppServiceProvider::boot()
use Illuminate\Support\Facades\Http;

Http::macro('github', function () {
    return Http::withHeaders([
        'Accept' => 'application/vnd.github.v3+json',
    ])->withToken(config('services.github.token'))
      ->baseUrl('https://api.github.com');
});

Http::macro('stripe', function () {
    return Http::withToken(config('services.stripe.secret'))
        ->baseUrl('https://api.stripe.com/v1')
        ->timeout(30)
        ->retry(3, 100);
});

Http::macro('internal', function () {
    return Http::baseUrl(config('services.internal.base_url'))
        ->withHeaders([
            'X-Service-Key' => config('services.internal.key'),
        ])
        ->timeout(10);
});

Использование:

// Clean, reusable API calls
$repos = Http::github()->get('/user/repos')->json();
$customers = Http::stripe()->get('/customers')->json();
$data = Http::internal()->get('/api/data')->json();

Middleware (Request Events)

use Illuminate\Http\Client\Events\RequestSending;
use Illuminate\Http\Client\Events\ResponseReceived;
use Illuminate\Http\Client\Events\ConnectionFailed;

// Listen to HTTP client events
Event::listen(RequestSending::class, function (RequestSending $event) {
    Log::debug('HTTP Request', [
        'method' => $event->request->method(),
        'url' => $event->request->url(),
    ]);
});

Event::listen(ResponseReceived::class, function (ResponseReceived $event) {
    Log::debug('HTTP Response', [
        'url' => $event->request->url(),
        'status' => $event->response->status(),
        'duration' => $event->response->transferStats?->getTransferTime(),
    ]);
});

Event::listen(ConnectionFailed::class, function (ConnectionFailed $event) {
    Log::error('HTTP Connection Failed', [
        'url' => $event->request->url(),
    ]);
});

Практический пример: API-клиент

declare(strict_types=1);

namespace App\Services;

use App\DTOs\WeatherData;
use App\Exceptions\WeatherApiException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

final class WeatherService
{
    private function client(): PendingRequest
    {
        return Http::baseUrl(config('services.weather.base_url'))
            ->withHeaders([
                'X-Api-Key' => config('services.weather.api_key'),
            ])
            ->timeout(15)
            ->retry(3, 200, fn ($exception) => !$exception instanceof RequestException || $exception->response->serverError())
            ->throw();
    }

    public function getCurrentWeather(string $city): WeatherData
    {
        return Cache::remember(
            "weather:{$city}",
            now()->addMinutes(30),
            function () use ($city) {
                try {
                    $response = $this->client()->get('/current', [
                        'city' => $city,
                        'units' => 'metric',
                    ]);

                    return WeatherData::fromArray($response->json('data'));
                } catch (RequestException $e) {
                    throw new WeatherApiException(
                        "Failed to fetch weather for {$city}: " . $e->getMessage(),
                        previous: $e,
                    );
                }
            }
        );
    }

    /**
     * Fetch weather for multiple cities concurrently.
     *
     * @param array<int, string> $cities
     * @return array<string, WeatherData>
     */
    public function getWeatherForCities(array $cities): array
    {
        $responses = Http::pool(fn ($pool) => collect($cities)->map(
            fn (string $city) => $pool
                ->as($city)
                ->withHeaders(['X-Api-Key' => config('services.weather.api_key')])
                ->baseUrl(config('services.weather.base_url'))
                ->timeout(15)
                ->get('/current', ['city' => $city, 'units' => 'metric'])
        )->toArray());

        $results = [];
        foreach ($cities as $city) {
            if ($responses[$city]->successful()) {
                $results[$city] = WeatherData::fromArray($responses[$city]->json('data'));
            }
        }

        return $results;
    }
}

Тестирование HTTP-клиента

Фейковые ответы

use Illuminate\Support\Facades\Http;

// Fake ALL requests
Http::fake();

// Fake with specific response
Http::fake([
    'api.example.com/users' => Http::response(['data' => []], 200),
    'api.example.com/users/*' => Http::response(['name' => 'John'], 200),
    'api.stripe.com/*' => Http::response(['error' => 'Unauthorized'], 401),
    '*' => Http::response('Not Found', 404),
]);

// Fake with sequence
Http::fake([
    'api.example.com/*' => Http::sequence()
        ->push(['data' => 'first'], 200)
        ->push(['data' => 'second'], 200)
        ->pushStatus(429)
        ->whenEmpty(Http::response(['data' => 'default'], 200)),
]);

// Fake with callback
Http::fake(function ($request) {
    if (str_contains($request->url(), '/users')) {
        return Http::response(['users' => []], 200);
    }
    return Http::response('', 404);
});

Assertions

declare(strict_types=1);

namespace Tests\Feature;

use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class WeatherServiceTest extends TestCase
{
    public function test_fetches_current_weather(): void
    {
        Http::fake([
            'api.weather.com/current*' => Http::response([
                'data' => [
                    'temp' => 22.5,
                    'humidity' => 65,
                    'description' => 'Partly cloudy',
                ],
            ], 200),
        ]);

        $service = app(WeatherService::class);
        $weather = $service->getCurrentWeather('Moscow');

        $this->assertEquals(22.5, $weather->temperature);
        $this->assertEquals(65, $weather->humidity);

        // Assert request was sent
        Http::assertSent(function ($request) {
            return str_contains($request->url(), '/current')
                && $request['city'] === 'Moscow'
                && $request['units'] === 'metric';
        });

        Http::assertSentCount(1);
    }

    public function test_handles_api_error(): void
    {
        Http::fake([
            'api.weather.com/*' => Http::response('Server Error', 500),
        ]);

        $this->expectException(WeatherApiException::class);

        $service = app(WeatherService::class);
        $service->getCurrentWeather('InvalidCity');
    }

    public function test_concurrent_requests(): void
    {
        Http::fake([
            'api.weather.com/current*' => Http::response([
                'data' => ['temp' => 20, 'humidity' => 50, 'description' => 'Clear'],
            ]),
        ]);

        $service = app(WeatherService::class);
        $results = $service->getWeatherForCities(['Moscow', 'London', 'Tokyo']);

        $this->assertCount(3, $results);
        Http::assertSentCount(3);
    }

    public function test_no_external_requests_made(): void
    {
        Http::fake();

        // Action that should not make external calls
        $this->get('/cached-page');

        Http::assertNothingSent();
    }

    public function test_request_headers(): void
    {
        Http::fake();

        Http::withToken('test-token')->get('https://api.example.com/data');

        Http::assertSent(function ($request) {
            return $request->hasHeader('Authorization', 'Bearer test-token');
        });
    }
}

Prevent Stray Requests

// Prevent any real HTTP requests during tests
Http::preventStrayRequests();

// Now any unfaked request will throw an exception
Http::get('https://real-api.com/data');
// Throws: Attempted to make a real HTTP request...

Проверь себя

Как работает первый аргумент метода retry(3, 100)?

Что делает Http::preventStrayRequests() в тестах?

Что такое макросы HTTP-клиента и для чего они используются?

Как Http::sequence() работает при фейковых ответах?