---
title: "HTTP clients"
description: "cURL by default, any PSR-18 client when you would rather use your own, and a fake for tests."
url: "https://openemail.uk/docs/php/http-clients"
area: "PHP"
category: "Getting started"
---

# 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();
```

| 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.

**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' => 'billing@acme.com',
    'to' => 'ada@example.com',
    '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.
