Skip to the documentation
PHP

HTTP clients

cURL by default, any PSR-18 client when you would rather use your own, and a fake for tests.

The default

With no httpClient:, requests go through OpenEmail\Http\CurlHttpClient, which needs nothing but the curl extension. It keeps one cURL handle per client, so a second request reuses the connection the first one opened, and the Configuration page lists its options for proxies, certificate authorities and extra cURL settings.

Whatever moves the bytes, the client itself does everything else: the credential, the idempotency keys, the retries and their backoff, the timeout for each attempt and the exception for each failure. An HTTP client only sends one request and hands back the response.

Any PSR-18 client

OpenEmail\Http\Psr18HttpClient wraps any client that implements PSR-18, such as Guzzle or Symfony HttpClient, so requests go through the client your application already configures, with its middleware, logging and proxy settings.

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 builds its requests with PSR-17 factories. The adapter uses the client itself when it is also a factory, as Symfony’s Psr18Client is, then php-http/discovery when it is installed, then nyholm/psr7 or Guzzle’s own, and throws InvalidArgumentException naming what to install when it finds none. Pass requestFactory: and streamFactory: to choose them yourself. Symfony’s Psr18Client also needs the PSR-18 interfaces and a PSR-17 implementation, so install it with 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();
ConcernWhat happens
TimeoutsGuzzle receives the client’s timeout: on every request. PSR-18 has no way to pass a timeout to any other client, so set one on the client you wrap, as HttpClient::create(['timeout' => 30, 'max_redirects' => 0]) does above, or a request that never answers can wait forever.
Error statusesA PSR-18 client returns 4xx and 5xx responses rather than throwing, and the adapter tells Guzzle to do the same, so every refusal still becomes the right ApiException.
No answerA ClientExceptionInterface from the wrapped client becomes a NetworkException that keeps its message but not the exception, which holds the request and its Authorization header. isTimeout() is true when the message says the request timed out, and for any client but Guzzle the message says the timeout was the wrapped client’s own.
RedirectsGuzzle is told not to follow them, and the Symfony example sets max_redirects to 0, so a redirect becomes an ApiException, as it does with the default cURL client.

Your own client

httpClient: takes anything that implements OpenEmail\Http\HttpClient, an interface with one method: send(HttpRequest $request): HttpResponse. Return the response for every status, 4xx and 5xx included, and throw only when no response arrived.

ClassWhat it carries
HttpRequestmethod, url, headers as an array of name and value, body as a string or null, and timeout in seconds, or null for none. header($name) reads one header in any case. var_dump(), print_r() and json_encode() show its Authorization header as [redacted], but var_export() and Symfony’s dump() print headers as they are, so never log a request with those two.
HttpResponseBuilt as new HttpResponse($status, $headers, $body). It lowercases the header names, and header($name) reads one in any case.
HttpClientExceptionThrow it from send when no response arrived. new HttpClientException($message, timeout: true) marks a timeout.

Testing without a network

A fake client records what would have gone out and answers with whatever the test needs, so nothing leaves the machine and the test can check exactly what was sent.

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);
  • Return a status outside 2xx with the API’s error envelope as the body, such as {"error": {"type": "validation_error", "code": "invalid_parameter", "message": "..."}}, to get the matching ApiException subclass.
  • Throw new HttpClientException('timed out', timeout: true) from send to get a NetworkException whose isTimeout() is true. Any other Exception, such as a RuntimeException, becomes a NetworkException whose isTimeout() is false.
  • A LogicException and any Error, such as a TypeError, count as bugs in the fake. They are thrown unchanged and never retried.

Build a test client with maxRetries: 0 when you script failures. Otherwise a retryable status or a network failure on a call that is safe to repeat is tried three times, with real sleeps in between.