Timeouts, Retries and Failures
Bound every outbound call with a timeout, retry only what is safe to repeat, and keep the worst case inside your own budget.
$response = $client->request('GET', $url, ['timeout' => 2, 'max_duration' => 10]);An outbound call with no time limit can hold a PHP worker process for as long as the other side keeps the socket open. Enough stuck calls and your own site stops answering, even though nothing in your code is broken. Two options bound the wait, and they measure different things.
timeout and max_duration
timeout is the idle limit: how long the transfer may sit with no network activity before a TransportExceptionInterface is thrown. When you do not set it, the client uses PHP's default_socket_timeout ini setting, and a negative value is treated as two days. max_duration caps the whole request and response, and defaults to 0, which means no cap at all. A large download wants a generous max_duration with a small timeout; a small JSON call wants both small. Set them, rather than inheriting whatever the ini file says.
Retries
retry_failed wraps a client so that a failed request is sent again. It is off until you configure it, and configuring any of its keys turns it on. The defaults are max_retries: 3, delay: 1000 milliseconds, multiplier: 2, max_delay: 0 and jitter: 0.1, giving waits of roughly one, two and four seconds with ten percent randomness so that many clients do not return together.
What is safe to retry
Repeating a request has to be harmless. By default 423, 425, 429, 502 and 503 are retried for any method, while 500, 504, 507, 510 and transport errors, listed as code 0, are retried only for the idempotent methods, which GenericRetryStrategy::IDEMPOTENT_METHODS lists as GET, HEAD, PUT, DELETE, OPTIONS, TRACE and QUERY. A POST that times out is therefore not retried: the server may have processed the first one.
Example
<?php
// src/Service/RateProvider.php
declare(strict_types=1);
namespace App\Service;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class RateProvider
{
public function __construct(
private HttpClientInterface $ratesClient,
) {
}
public function eurToUsd(): ?float
{
try {
$response = $this->ratesClient->request('GET', 'rates/EURUSD', [
// Give up if the connection sits idle for 2 seconds...
'timeout' => 2,
// ...and give up after 5 seconds in total, whatever happens.
'max_duration' => 5,
]);
// toArray() throws for any status in the 300-599 range, for a
// transport failure, and for a body that is not valid JSON.
// All three implement this one interface.
return (float) $response->toArray()['rate'];
} catch (ExceptionInterface) {
// The caller gets null and decides what to show.
return null;
}
}
}
// Given the body {"rate":1.0825}
// Output: 1.0825
// With the remote host unreachable, or answering 503
// Output: nullWhen to use it
- Stop a slow third-party API from holding your PHP workers until the request times out.
- Retry a 503 from an internal service a couple of times instead of failing a page render.
- Decide not to retry a payment POST, because the first attempt may already have charged the card.
- Return a cached or default value when a provider is unreachable, rather than a 500 page.
More examples
Timeouts and a retry policy in configuration
Configuring any key under retry_failed enables retrying for that client. The http_codes map is read two ways: a value of true retries that status for every method, while a list of methods retries it only for those. Setting 0 covers failures where no response arrived at all. This policy costs at most 5 seconds per attempt and about 1.5 seconds of waiting, so the worst case stays near 16 seconds, which you can compare against your own request budget.
# config/packages/framework.yaml
framework:
http_client:
default_options:
timeout: 2 # idle limit, in seconds
max_duration: 10 # total limit, in seconds; 0 means unlimited
scoped_clients:
rates.client:
base_uri: 'https://api.example.com/'
timeout: 2
max_duration: 5
retry_failed:
max_retries: 2
delay: 500 # milliseconds before the first retry
multiplier: 2 # 500ms, then 1000ms
max_delay: 2000 # never wait longer than this
jitter: 0.2 # randomise each delay by +/- 20%
http_codes:
0: ['GET', 'HEAD'] # transport errors, safe methods only
429: true # too many requests, any method
503: true # service unavailable, any methodThe same retry defaults written out by hand
Both methods return the same thing: RetryableHttpClient falls back to a new GenericRetryStrategy() and three retries when you pass neither. Writing the longhand is how you see what the defaults actually are, and GenericRetryStrategy::DEFAULT_RETRY_STATUS_CODES is worth printing once to read the method lists it contains. Use this form outside a Symfony application, or when you need a strategy of your own; inside the framework the retry_failed configuration builds the same wrapper for you.
<?php
// src/Http/RetryingClientFactory.php
declare(strict_types=1);
namespace App\Http;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
use Symfony\Component\HttpClient\RetryableHttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class RetryingClientFactory
{
public static function shorthand(): HttpClientInterface
{
$inner = HttpClient::create(['timeout' => 2, 'max_duration' => 10]);
// Wraps the client with the default strategy and 3 retries.
return new RetryableHttpClient($inner);
}
public static function longhand(): HttpClientInterface
{
$inner = HttpClient::create(['timeout' => 2, 'max_duration' => 10]);
// The same behaviour, spelled out. Arguments in order:
// status codes, delay in ms, multiplier, max delay in ms, jitter.
$strategy = new GenericRetryStrategy(
GenericRetryStrategy::DEFAULT_RETRY_STATUS_CODES,
1000,
2.0,
0,
0.1,
);
return new RetryableHttpClient($inner, $strategy, 3);
}
}Telling a bad answer apart from no answer
The two interfaces answer different questions. HttpExceptionInterface means the server replied and you did not like the status, and its getResponse() gives you the headers and the body to act on. TransportExceptionInterface means nothing came back, so there is nothing to read. Header names are lower-cased and each value is a list, which is why the Retry-After value is read as ['retry-after'][0], and getHeaders(false) is needed because the plain call would throw again on the error status.
<?php
// src/Service/SearchGateway.php
declare(strict_types=1);
namespace App\Service;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class SearchGateway
{
public function __construct(
private HttpClientInterface $searchClient,
) {
}
/**
* @return array{status: string, retry_after?: int}
*/
public function status(string $term): array
{
try {
$this->searchClient->request('GET', 'search', [
'query' => ['q' => $term],
'timeout' => 1,
'max_duration' => 3,
])->toArray();
return ['status' => 'ok'];
} catch (HttpExceptionInterface $e) {
// A response did arrive, so it can be inspected.
$response = $e->getResponse();
if (429 === $response->getStatusCode()) {
$after = $response->getHeaders(false)['retry-after'][0] ?? '60';
return ['status' => 'rate_limited', 'retry_after' => (int) $after];
}
return ['status' => 'upstream_error'];
} catch (TransportExceptionInterface) {
// No response at all: DNS, TCP, TLS or a timeout.
return ['status' => 'unreachable'];
}
}
}
// Given a 429 response with Retry-After: 30
// Output: ['status' => 'rate_limited', 'retry_after' => 30]
Discussion