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.
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 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.
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();| Concern | What happens |
|---|---|
| Timeouts | Guzzle 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 statuses | A 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 answer | A 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. |
| Redirects | Guzzle 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.
| Class | What it carries |
|---|---|
| HttpRequest | method, 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. |
| HttpResponse | Built as new HttpResponse($status, $headers, $body). It lowercases the header names, and header($name) reads one in any case. |
| HttpClientException | Throw 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.
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 matchingApiExceptionsubclass. - Throw
new HttpClientException('timed out', timeout: true)fromsendto get aNetworkExceptionwhoseisTimeout()is true. Any otherException, such as aRuntimeException, becomes aNetworkExceptionwhoseisTimeout()is false. - A
LogicExceptionand anyError, such as aTypeError, 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.