HTTP-клиенты
cURL по умолчанию, любой клиент PSR-18, если вы предпочитаете свой, и подделка для тестов.
По умолчанию
Без httpClient: запросы идут через OpenEmail\Http\CurlHttpClient, которому нужно только расширение curl. Он держит один дескриптор cURL на клиент, поэтому второй запрос переиспользует соединение, открытое первым, а страница «Конфигурация» перечисляет его опции для прокси, центров сертификации и дополнительных настроек cURL.
Что бы ни передавало байты, всё остальное делает сам клиент: учётные данные, ключи идемпотентности, повторы с их схемой отката, таймаут каждой попытки и исключение для каждого сбоя. HTTP-клиент лишь отправляет один запрос и возвращает ответ.
Любой клиент PSR-18
OpenEmail\Http\Psr18HttpClient оборачивает любой клиент, реализующий PSR-18, например Guzzle или Symfony HttpClient, поэтому запросы идут через клиент, который ваше приложение уже настраивает, с его middleware, журналированием и настройками прокси.
use GuzzleHttp\Client;use OpenEmail\Http\Psr18HttpClient;use OpenEmail\OpenEmail; $client = new OpenEmail(httpClient: new Psr18HttpClient(new Client())); $client->me->ping();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.
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, и выбрасывайте исключение, только когда ответ не пришёл.
| Класс | Что несёт |
|---|---|
| HttpRequest | method, 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) помечает таймаут. |
Тестирование без сети
Поддельный клиент записывает то, что ушло бы наружу, и отвечает тем, что нужно тесту, поэтому ничто не покидает машину, а тест может проверить, что именно было отправлено.
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, когда программируете сбои. Иначе вызов, который безопасно повторять, при статусе, допускающем повтор, или при сетевом сбое выполняется трижды, с настоящими паузами между попытками.