HTTP client¶
Namespace Keel\HttpClient. A PSR-18 client on ext-curl, wired as
Psr\Http\Client\ClientInterface, with requests built from the PSR-17 factories the kernel
already registers. Guzzle or symfony/http-client drop in by aliasing the interface instead.
// config/http_client.php
return new HttpClientConfig(
timeout: 10.0, // whole request, seconds
connectTimeout: 3.0,
maxRedirects: 5, // 0 disables following redirects
verifyTls: true,
userAgent: 'myapp/1.0',
headers: ['Accept' => 'application/json'], // unless the request sets the header
proxy: null, // "http://proxy:3128"
);
// config/services.php
return new ServicesConfig([HttpClientProvider::class]);
final class GeoClient
{
public function __construct(
private readonly ClientInterface $http,
private readonly RequestFactoryInterface $requests,
) {}
public function lookup(string $ip): array
{
$request = $this->requests->createRequest('GET', 'https://geo.example.com/' . rawurlencode($ip))
->withHeader('Authorization', 'Bearer ' . $this->token);
$response = $this->http->sendRequest($request);
if ($response->getStatusCode() !== 200) {
throw new \RuntimeException('geo lookup failed: ' . $response->getStatusCode());
}
return json_decode((string) $response->getBody(), true, flags: \JSON_THROW_ON_ERROR);
}
}
Behaviour¶
- Status codes are returned, never thrown, as PSR-18 requires.
NetworkException(DNS, connection, TLS, timeout;getCode()is the curl error number) andRequestException(unsupported scheme) implement the PSR-18 exception interfaces. - Redirects are followed up to
maxRedirects, by the client itself rather than by curl, so they behave like a browser: 301, 302 and 303 turn a request with a body into aGETwithout one, 307 and 308 repeat the request as sent,Authorization,Proxy-AuthorizationandCookieare dropped when the target is another origin (scheme, host or port), relativeLocationvalues are resolved against the request URI, and onlyhttp/httpstargets are followed. The response is the last hop's; when the limit is reached, the last redirect response is returned as is. - Response header names are matched case-insensitively (
Set-Cookieandset-cookieare one header with every value kept), folded continuation lines are joined, and a response the PSR-7 factory refuses is aRequestException. - The body of
POST,PUT,PATCHandDELETErequests is sent as is;GETandHEADnever send one. Responses are buffered in memory: for large downloads stream through your own curl call or a dedicated client. - One curl handle per client instance is reused across requests of a worker, so keep-alive connections to the same host survive between requests. After a failure the handle is recreated.
Retries and circuit breaker¶
Two PSR-18 decorators, enabled from the config and stacked over CurlClient by the provider
(retries outside, the breaker inside, so every attempt passes the breaker). They wrap any PSR-18
client, Guzzle included, when built by hand.
// config/http_client.php
return new HttpClientConfig(
timeout: 5.0,
retry: new RetryConfig(
maxAttempts: 3, // the first attempt included
delay: 0.1, // before the first retry, then x multiplier
maxDelay: 2.0, // cap of one wait, Retry-After included
multiplier: 2.0,
jitter: true, // wait between half and all of the delay
statuses: [408, 429, 500, 502, 503, 504],
),
circuitBreaker: new CircuitBreakerConfig(
failures: 5, // consecutive failures that open the circuit of a host
openFor: 30, // seconds of refusing calls, then one probe
window: 60, // failures are forgotten this long after the last one
statuses: [500, 502, 503, 504],
shared: true, // state in APCu: the workers of a server open it together
),
);
RetryingClient repeats a request after a network failure or one of statuses:
- Only requests safe to repeat:
GET,HEAD,OPTIONS,PUT,DELETE,TRACE, or any method with anIdempotency-Keyheader (pair it with#[Idempotent]on the server side). A body that cannot be rewound is never sent twice; a seekable one is rewound before each attempt. - Waits
delay × multiplier^(n-1), capped atmaxDelay, with jitter. ARetry-After(seconds or an HTTP date) replaces the backoff; aRetry-AfterabovemaxDelayreturns the response at once, since the wait would hold the worker that serves a client. - The result is the last attempt's response or exception.
RequestException(the request itself is wrong) andCircuitOpenExceptionare never retried. Each retry is logged atinfowithout the query string.
Retries run inside the request that needs the answer: with timeout: 5.0 and three attempts a
client can wait over 15 s. Keep timeout, maxAttempts and maxDelay small on the request path.
CircuitBreakerClient keeps one circuit per origin (scheme, host, port):
- Closed: calls go out; network failures and
statusesare counted, any other response resets the count. 429 is not a failure by default: the host is up and asks to slow down. - Open after
failuresin a row: calls fail at once withCircuitOpenException(a PSR-18NetworkExceptionInterface, with the request and$circuit), without touching the network. Opening is logged as a warning. - Half-open after
openForseconds: the first call is a probe and the others are refused for anotheropenFor; the probe's success closes the circuit, its failure opens it again.
With shared: true and APCu loaded, the state is shared by every php-fpm child or FrankenPHP
thread of the server, so the circuit opens after failures calls in total rather than in every
worker; concurrent failures are counted approximately. Without APCu each worker has its own
circuits. CircuitBreaker also works alone, for calls that are not HTTP:
$breaker = new CircuitBreaker(new CircuitBreakerConfig(failures: 3), $cache); // any PSR-16 store
if (!$breaker->allow('search')) {
return $this->fallback();
}
try {
$result = $this->search->query($q);
$breaker->success('search');
} catch (\Throwable $e) {
$breaker->failure('search');
throw $e;
}
$breaker->state('search'); // CircuitState::Closed, Open or HalfOpen
Worker mode¶
The client holds no request state; a failed request leaves nothing behind. Per-worker connection reuse is the main reason to keep the client a shared service rather than creating one per request.