http clients and apis
Timeouts And Retries
Every HTTP call can be slow, fail halfway through, or return a temporary error. Timeouts stop one request from hanging forever, and retries give a temporary failure a controlled second chance.
This is a core professional skill because PHP applications often depend on payment providers, CRMs, email services, mapping APIs, internal services, and queues. If those calls have no timeout, a single slow dependency can tie up PHP workers and make your own application look broken. If retries are careless, the application can create duplicate orders, charge twice, or make an outage worse.
The timeout problem
An HTTP client usually needs more than one timeout:
- A connect timeout limits how long the client waits to establish a connection.
- A request or response timeout limits the whole operation.
- A read timeout limits how long the client waits for more response data.
Different clients name these options differently. In Guzzle, you will commonly see connect_timeout and timeout. In cURL you may see CURLOPT_CONNECTTIMEOUT and CURLOPT_TIMEOUT.
<?php
declare(strict_types=1);
$guzzleOptions = [
'connect_timeout' => 2.0,
'timeout' => 8.0,
'headers' => [
'Accept' => 'application/json',
],
];
print_r($guzzleOptions);
// Prints:
// [connect_timeout] => 2
// [timeout] => 8
There is no universal perfect timeout. A user-facing page usually needs short timeouts because a person is waiting. A background import can often wait longer, but it still needs a limit.
What should be retried
Retries are for failures that might succeed shortly afterwards. Good candidates include:
- A connection timeout.
- A temporary DNS or network failure.
- HTTP
408 Request Timeout. - HTTP
429 Too Many Requests, usually respectingRetry-After. - HTTP
502 Bad Gateway,503 Service Unavailable, or504 Gateway Timeout.
Usually do not retry:
400 Bad Request, because the request is malformed.401 Unauthorizedor403 Forbidden, because credentials or permissions are wrong.404 Not Found, unless the API documents eventual consistency.422 Unprocessable Content, because validation failed.
The rule is not simply "retry all errors". Retry only when repeating the operation is likely to help and will not create harmful duplicates.
Safe methods and idempotency
GET, HEAD, PUT, and DELETE are normally designed to be safe to retry from the client's point of view, although real systems can still have side effects such as logging. POST often creates something new, so retrying it can be dangerous unless the API supports idempotency keys.
<?php
declare(strict_types=1);
function shouldRetry(string $method, int $statusCode, bool $hasIdempotencyKey): bool
{
$retryableStatuses = [408, 429, 502, 503, 504];
if (!in_array($statusCode, $retryableStatuses, true)) {
return false;
}
$method = strtoupper($method);
if (in_array($method, ['GET', 'HEAD', 'PUT', 'DELETE'], true)) {
return true;
}
return $method === 'POST' && $hasIdempotencyKey;
}
var_dump(shouldRetry('GET', 503, false));
var_dump(shouldRetry('POST', 503, false));
var_dump(shouldRetry('POST', 503, true));
// Prints:
// bool(true)
// bool(false)
// bool(true)
An idempotency key lets the server recognise a repeated request as the same operation. This is common in payment APIs and other create-once workflows.
Backoff
If a service is overloaded, immediately retrying every failed request can make the problem worse. Backoff means waiting longer between attempts.
<?php
declare(strict_types=1);
function retryDelayMilliseconds(int $attempt, ?int $retryAfterSeconds = null): int
{
if ($retryAfterSeconds !== null) {
return max(0, $retryAfterSeconds * 1000);
}
return min(8000, 250 * (2 ** max(0, $attempt - 1)));
}
foreach ([1, 2, 3, 4, 5, 6] as $attempt) {
echo 'attempt ' . $attempt . ': ' . retryDelayMilliseconds($attempt) . 'ms' . PHP_EOL;
}
// Prints:
// attempt 1: 250ms
// attempt 2: 500ms
// attempt 3: 1000ms
// attempt 4: 2000ms
// attempt 5: 4000ms
// attempt 6: 8000ms
Production retry systems often add jitter, which means a small random variation in the delay. Jitter prevents many workers from retrying at exactly the same moment.
A small retry loop
This example avoids real HTTP so the retry decision is easy to see.
<?php
declare(strict_types=1);
function callWithRetries(callable $send, int $maxAttempts): array
{
$attempts = [];
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$response = $send($attempt);
$attempts[] = $response['status'];
if (!shouldRetry('GET', $response['status'], false)) {
return [
'status' => $response['status'],
'attempts' => $attempts,
];
}
}
return [
'status' => end($attempts),
'attempts' => $attempts,
];
}
$result = callWithRetries(
fn (int $attempt): array => ['status' => $attempt < 3 ? 503 : 200],
3
);
print_r($result);
// Prints:
// [status] => 200
// [attempts] => [503, 503, 200]
The loop has a maximum attempt count. That is non-negotiable. Infinite retries hide faults and consume resources.
Logging retries
Retries should leave enough information for debugging:
- Which service and endpoint were called.
- The attempt number and maximum attempts.
- The status code or transport error.
- The request ID or correlation ID.
- Whether the operation had an idempotency key.
Do not log bearer tokens, API keys, passwords, or full payment data.
What to check
Before moving on, make sure you can:
- Explain why every external HTTP call needs a timeout.
- Decide which status codes are worth retrying.
- Avoid retrying unsafe
POSTrequests unless idempotency is in place. - Use a maximum attempt count and backoff.
- Respect
Retry-Afterwhen an API sends it.
Practice
Practice: Retry Decisions
Write a PHP function that decides whether an API call should be retried.
Requirements
- Accept the HTTP method, status code, current attempt number, maximum attempts, and whether an idempotency key is present.
- Retry only temporary statuses such as
408,429,502,503, and504. - Do not retry once the maximum attempt count has been reached.
- Treat
POSTas retryable only when an idempotency key is present. - Return both a boolean decision and the delay before the next attempt.
- Show examples for a retryable
GET, a non-retryablePOST, and a final attempt that should stop.
Show solution
This solution keeps the retry rule separate from the HTTP client. That makes it easy to test before wiring it into Guzzle, cURL, or a framework service.
<?php
declare(strict_types=1);
function retryDelayMilliseconds(int $attempt): int
{
return min(8000, 250 * (2 ** max(0, $attempt - 1)));
}
function retryDecision(
string $method,
int $statusCode,
int $attempt,
int $maxAttempts,
bool $hasIdempotencyKey
): array {
if ($attempt >= $maxAttempts) {
return ['retry' => false, 'delay_ms' => 0, 'reason' => 'maximum attempts reached'];
}
if (!in_array($statusCode, [408, 429, 502, 503, 504], true)) {
return ['retry' => false, 'delay_ms' => 0, 'reason' => 'status is not temporary'];
}
$method = strtoupper($method);
$safeMethod = in_array($method, ['GET', 'HEAD', 'PUT', 'DELETE'], true);
$safePost = $method === 'POST' && $hasIdempotencyKey;
if (!$safeMethod && !$safePost) {
return ['retry' => false, 'delay_ms' => 0, 'reason' => 'operation is not safe to repeat'];
}
return [
'retry' => true,
'delay_ms' => retryDelayMilliseconds($attempt),
'reason' => 'temporary failure',
];
}
$examples = [
retryDecision('GET', 503, 1, 3, false),
retryDecision('POST', 503, 1, 3, false),
retryDecision('POST', 503, 3, 3, true),
];
foreach ($examples as $decision) {
echo ($decision['retry'] ? 'retry' : 'stop')
. ' after ' . $decision['delay_ms'] . 'ms'
. ' because ' . $decision['reason']
. PHP_EOL;
}
// Prints:
// retry after 250ms because temporary failure
// stop after 0ms because operation is not safe to repeat
// stop after 0ms because maximum attempts reached
The important part is not the exact delay formula. The important part is that retries are limited, intentional, and aware of whether repeating the request is safe.