MidПрактика5 min

Symfony HttpClient

HttpClientInterface, request options, streaming, retry, scoping — все способы использования

HttpClient — компонент Symfony для отправки HTTP-запросов. Поддерживает async, streaming, retry, scoping и интеграцию с PSR-18. На экзамене проверяют знание API и паттернов использования.

Базовое использование

<?php

declare(strict_types=1);

use Symfony\Contracts\HttpClient\HttpClientInterface;

class GitHubService
{
    public function __construct(
        private readonly HttpClientInterface $httpClient,
    ) {
    }

    public function getUser(string $username): array
    {
        $response = $this->httpClient->request('GET', "https://api.github.com/users/{$username}", [
            'headers' => [
                'Accept' => 'application/vnd.github.v3+json',
            ],
        ]);

        // Status code
        $statusCode = $response->getStatusCode(); // 200

        // Response headers
        $contentType = $response->getHeaders()['content-type'][0];

        // Decoded JSON (auto-detects from Content-Type)
        $data = $response->toArray();

        return $data;
    }
}

Request Options

<?php

declare(strict_types=1);

// GET with query parameters
$response = $httpClient->request('GET', 'https://api.example.com/search', [
    'query' => [
        'q' => 'symfony',
        'page' => 1,
        'limit' => 20,
    ],
]);
// Sends: GET /search?q=symfony&page=1&limit=20

// POST with JSON body
$response = $httpClient->request('POST', 'https://api.example.com/products', [
    'json' => [
        'name' => 'Widget',
        'price' => 9.99,
    ],
    // Automatically sets Content-Type: application/json
]);

// POST with form data
$response = $httpClient->request('POST', 'https://api.example.com/login', [
    'body' => [
        'username' => 'admin',
        'password' => 'secret',
    ],
    // Sends: application/x-www-form-urlencoded
]);

// Custom headers
$response = $httpClient->request('GET', 'https://api.example.com/data', [
    'headers' => [
        'Authorization' => 'Bearer token123',
        'X-Custom-Header' => 'value',
    ],
]);

// Timeout configuration
$response = $httpClient->request('GET', 'https://slow-api.example.com/', [
    'timeout' => 10.0,           // Total request timeout (seconds)
    'max_duration' => 30.0,      // Max time for the whole request
]);

Все доступные опции

Опция Тип Описание
headers array Заголовки запроса
query array Query string параметры
body string/array Тело запроса
json mixed JSON-тело (auto Content-Type)
auth_basic string HTTP Basic auth (user:password)
auth_bearer string Bearer token
timeout float Таймаут соединения (сек)
max_duration float Макс. длительность запроса
max_redirects int Макс. число редиректов (20)
base_uri string Базовый URL
verify_peer bool Проверять SSL-сертификат
proxy string HTTP-прокси

Scoped Clients

Scoped clients позволяют настроить клиент для конкретного API с предустановленными параметрами.

# config/packages/framework.yaml
framework:
    http_client:
        scoped_clients:
            github.client:
                base_uri: 'https://api.github.com'
                headers:
                    Accept: 'application/vnd.github.v3+json'
                    Authorization: 'Bearer %env(GITHUB_TOKEN)%'
            stripe.client:
                base_uri: 'https://api.stripe.com/v1'
                auth_basic: '%env(STRIPE_SECRET_KEY)%:'
<?php

declare(strict_types=1);

use Symfony\Contracts\HttpClient\HttpClientInterface;

class GitHubService
{
    // Symfony auto-wires the scoped client by variable name
    public function __construct(
        private readonly HttpClientInterface $githubClient,
    ) {
    }

    public function getRepos(): array
    {
        // Base URI and headers are pre-configured
        $response = $this->githubClient->request('GET', '/user/repos');
        return $response->toArray();
    }
}

Подвох экзамена: Scoped client автоматически инжектится по имени переменной в конструкторе. Если scoped client называется github.client, то переменная должна быть $githubClient (camelCase без точки). Symfony автоматически маппит github.client → $githubClient.

Асинхронные запросы

<?php

declare(strict_types=1);

use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

class AsyncFetcher
{
    public function __construct(
        private readonly HttpClientInterface $httpClient,
    ) {
    }

    public function fetchMultiple(array $urls): array
    {
        // Start all requests concurrently
        $responses = [];
        foreach ($urls as $key => $url) {
            $responses[$key] = $this->httpClient->request('GET', $url);
        }

        // Responses are lazy — actual HTTP call happens on first access
        $results = [];
        foreach ($responses as $key => $response) {
            $results[$key] = $response->toArray();
        }

        return $results;
    }

    // Stream responses as they arrive
    public function fetchStream(array $urls): \Generator
    {
        $responses = [];
        foreach ($urls as $url) {
            $responses[] = $this->httpClient->request('GET', $url);
        }

        // stream() yields chunks as they arrive
        foreach ($this->httpClient->stream($responses) as $response => $chunk) {
            if ($chunk->isFirst()) {
                // First chunk — headers available
                yield 'status' => $response->getStatusCode();
            }

            if ($chunk->isLast()) {
                // Last chunk — response complete
                yield 'data' => $response->toArray();
            }
        }
    }
}

Подвох экзамена: HttpClient в Symfony ленивый (lazy). request() НЕ отправляет HTTP-запрос немедленно. Реальный запрос происходит при первом обращении к ответу (getStatusCode(), toArray(), etc.). Это позволяет запустить несколько запросов параллельно.

Retry Failed Requests

<?php

declare(strict_types=1);

use Symfony\Component\HttpClient\RetryableHttpClient;
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;

// Programmatic retry configuration
$retryStrategy = new GenericRetryStrategy(
    delayMs: 1000,           // Initial delay: 1 second
    multiplier: 2.0,         // Exponential backoff
    maxDelayMs: 30000,       // Max delay: 30 seconds
    jitter: 0.3,             // Random jitter: ±30%
);

$client = new RetryableHttpClient(
    $httpClient,
    $retryStrategy,
    maxRetries: 3,
);
# config/packages/framework.yaml
framework:
    http_client:
        default_options:
            retry_failed:
                enabled: true
                max_retries: 3
                delay: 1000
                multiplier: 2
                max_delay: 30000
                jitter: 0.3
                http_codes:
                    - 423  # Locked
                    - 425  # Too Early
                    - 429  # Too Many Requests
                    - 500  # Internal Server Error
                    - 502  # Bad Gateway
                    - 503  # Service Unavailable

Symfony 8.0: В Symfony 8.0 retry configuration поддерживает retry_on_exception для указания типов исключений, при которых нужен retry. Также добавлена поддержка Retry-After header — клиент автоматически ждёт указанное время.

Error Handling

<?php

declare(strict_types=1);

use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;

try {
    $response = $httpClient->request('GET', 'https://api.example.com/data');
    $data = $response->toArray();
} catch (ClientExceptionInterface $e) {
    // 4xx errors (client error)
    $statusCode = $e->getResponse()->getStatusCode();
} catch (ServerExceptionInterface $e) {
    // 5xx errors (server error)
    $statusCode = $e->getResponse()->getStatusCode();
} catch (RedirectionExceptionInterface $e) {
    // 3xx errors (too many redirects)
} catch (TransportExceptionInterface $e) {
    // Network errors (timeout, DNS failure, etc.)
}

// Alternative: check status without exception
$response = $httpClient->request('GET', 'https://api.example.com/data');
$statusCode = $response->getStatusCode(); // No exception for 4xx/5xx HERE

// Exception is thrown on content access methods:
// toArray(), getContent(), getHeaders() throw on 3xx/4xx/5xx
// Use throw: false to disable
$response = $httpClient->request('GET', 'https://api.example.com/data');
$content = $response->getContent(false); // No exception even for 4xx/5xx

Подвох экзамена: getStatusCode() НИКОГДА не бросает исключения (даже для 4xx/5xx). Исключения бросаются при toArray(), getContent(), getHeaders() для ошибочных статусов. Передайте false аргументом чтобы отключить это поведение.

Тестирование HttpClient

<?php

declare(strict_types=1);

use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

// Create mock client for tests
$mockResponses = [
    new MockResponse(json_encode(['id' => 1, 'name' => 'Widget']), [
        'http_code' => 200,
        'response_headers' => ['Content-Type: application/json'],
    ]),
    new MockResponse('Not Found', [
        'http_code' => 404,
    ]),
];

$client = new MockHttpClient($mockResponses);

// First request returns 200 with JSON
$response = $client->request('GET', '/products/1');
$data = $response->toArray(); // ['id' => 1, 'name' => 'Widget']

// Second request returns 404
$response = $client->request('GET', '/products/999');
$statusCode = $response->getStatusCode(); // 404
<?php

declare(strict_types=1);

// Callback-based mock for dynamic responses
$client = new MockHttpClient(function (string $method, string $url): MockResponse {
    if (str_contains($url, '/products/')) {
        return new MockResponse(json_encode(['found' => true]), [
            'http_code' => 200,
        ]);
    }

    return new MockResponse('', ['http_code' => 404]);
});