Перейти к документации
PHP

HTTP-клиенты

cURL по умолчанию, любой клиент PSR-18, если вы предпочитаете свой, и подделка для тестов.

По умолчанию

Без httpClient: запросы идут через OpenEmail\Http\CurlHttpClient, которому нужно только расширение curl. Он держит один дескриптор cURL на клиент, поэтому второй запрос переиспользует соединение, открытое первым, а страница «Конфигурация» перечисляет его опции для прокси, центров сертификации и дополнительных настроек cURL.

Что бы ни передавало байты, всё остальное делает сам клиент: учётные данные, ключи идемпотентности, повторы с их схемой отката, таймаут каждой попытки и исключение для каждого сбоя. HTTP-клиент лишь отправляет один запрос и возвращает ответ.

Любой клиент PSR-18

OpenEmail\Http\Psr18HttpClient оборачивает любой клиент, реализующий PSR-18, например Guzzle или Symfony HttpClient, поэтому запросы идут через клиент, который ваше приложение уже настраивает, с его middleware, журналированием и настройками прокси.

guzzle.php
use GuzzleHttp\Client;use OpenEmail\Http\Psr18HttpClient;use OpenEmail\OpenEmail; $client = new OpenEmail(httpClient: new Psr18HttpClient(new Client())); $client->me->ping();
symfony_http_client.php
use OpenEmail\Http\Psr18HttpClient;use OpenEmail\OpenEmail;use Symfony\Component\HttpClient\HttpClient;use Symfony\Component\HttpClient\Psr18Client; $http = new Psr18Client(HttpClient::create(['timeout' => 30, 'max_redirects' => 0])); $client = new OpenEmail(httpClient: new Psr18HttpClient($http)); $client->me->ping();

PSR-18 строит свои запросы с помощью фабрик PSR-17. Адаптер использует сам клиент, если он тоже является фабрикой, как Psr18Client из Symfony, затем php-http/discovery, если он установлен, затем nyholm/psr7 или собственные фабрики Guzzle, а если не находит ни одной, выбрасывает InvalidArgumentException с указанием того, что нужно установить. Передайте requestFactory: и streamFactory:, чтобы выбрать их самостоятельно. Для Psr18Client из Symfony также нужны интерфейсы PSR-18 и реализация PSR-17, поэтому установите его командой composer require symfony/http-client psr/http-client nyholm/psr7.

factories.php
use GuzzleHttp\Client;use Nyholm\Psr7\Factory\Psr17Factory;use OpenEmail\Http\Psr18HttpClient;use OpenEmail\OpenEmail; $factory = new Psr17Factory(); $client = new OpenEmail(httpClient: new Psr18HttpClient(new Client(), requestFactory: $factory, streamFactory: $factory)); $client->me->ping();
АспектЧто происходит
ТаймаутыGuzzle получает timeout: клиента в каждом запросе. В PSR-18 нет способа передать таймаут любому другому клиенту, поэтому задайте его в оборачиваемом клиенте, как это делает HttpClient::create(['timeout' => 30, 'max_redirects' => 0]) выше, иначе запрос, на который никогда не ответят, может ждать вечно.
Статусы ошибокКлиент PSR-18 возвращает ответы 4xx и 5xx, а не выбрасывает исключение, и адаптер велит Guzzle делать так же, поэтому каждый отказ всё равно становится нужным ApiException.
Нет ответаClientExceptionInterface от обёрнутого клиента становится NetworkException, который сохраняет его сообщение, но не само исключение, потому что оно содержит запрос и его заголовок Authorization. isTimeout() равно true, когда в сообщении сказано, что время запроса истекло, а для любого клиента, кроме Guzzle, сообщение уточняет, что это был собственный таймаут обёрнутого клиента.
ПеренаправленияGuzzle получает указание не следовать им, а пример для Symfony задаёт max_redirects равным 0, поэтому перенаправление превращается в ApiException, как и с клиентом cURL по умолчанию.

Собственный клиент

httpClient: принимает всё, что реализует OpenEmail\Http\HttpClient, интерфейс с одним методом: send(HttpRequest $request): HttpResponse. Возвращайте ответ для любого статуса, включая 4xx и 5xx, и выбрасывайте исключение, только когда ответ не пришёл.

КлассЧто несёт
HttpRequestmethod, url, headers как массив имён и значений, body как строка или null и timeout в секундах или null, если таймаута нет. header($name) читает один заголовок без учёта регистра. var_dump(), print_r() и json_encode() показывают его заголовок Authorization как [redacted], но var_export() и dump() из Symfony выводят headers как есть, поэтому никогда не записывайте запрос в лог этими двумя.
HttpResponseСоздаётся как new HttpResponse($status, $headers, $body). Приводит имена заголовков к нижнему регистру, а header($name) читает заголовок без учёта регистра.
HttpClientExceptionВыбрасывайте его из send, когда ответ не пришёл. new HttpClientException($message, timeout: true) помечает таймаут.

Тестирование без сети

Поддельный клиент записывает то, что ушло бы наружу, и отвечает тем, что нужно тесту, поэтому ничто не покидает машину, а тест может проверить, что именно было отправлено.

fake_http_client.php
use OpenEmail\Http\HttpClient;use OpenEmail\Http\HttpRequest;use OpenEmail\Http\HttpResponse;use OpenEmail\OpenEmail; $fake = new class implements HttpClient {    public array $requests = [];     public function send(HttpRequest $request): HttpResponse    {        $this->requests[] = $request;         return new HttpResponse(            200,            ['content-type' => 'application/json'],            json_encode(['id' => 'msg_test', 'status' => 'sent', 'replayed' => false], JSON_THROW_ON_ERROR),        );    }}; $testClient = new OpenEmail(apiKey: 'oe_test_fake', httpClient: $fake, maxRetries: 0, disableUpdateNotice: true); $sent = $testClient->emails->send([    'from' => '[email protected]',    'to' => '[email protected]',    'subject' => 'Hi',    'text' => 'Hello',]); $request = $fake->requests[0]; echo $sent['status'], ' ', $request->method, ' ', $request->url, ' ', $request->header('Idempotency-Key'), PHP_EOL;var_dump($request);
  • Верните статус вне диапазона 2xx с конвертом ошибки API в теле, например {"error": {"type": "validation_error", "code": "invalid_parameter", "message": "..."}}, чтобы получить соответствующий подкласс ApiException.
  • Выбросьте из send исключение new HttpClientException('timed out', timeout: true), чтобы получить NetworkException, у которого isTimeout() равно true. Любое другое Exception, например RuntimeException, становится NetworkException, у которого isTimeout() равно false.
  • LogicException и любой Error, например TypeError, считаются ошибками в самой подделке. Они выбрасываются без изменений и никогда не повторяются.

Собирайте тестовый клиент с maxRetries: 0, когда программируете сбои. Иначе вызов, который безопасно повторять, при статусе, допускающем повтор, или при сетевом сбое выполняется трижды, с настоящими паузами между попытками.