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 example
<?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 respecting Retry-After.
  • HTTP 502 Bad Gateway, 503 Service Unavailable, or 504 Gateway Timeout.

Usually do not retry:

  • 400 Bad Request, because the request is malformed.
  • 401 Unauthorized or 403 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 example
<?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 example
<?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 example
<?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 POST requests unless idempotency is in place.
  • Use a maximum attempt count and backoff.
  • Respect Retry-After when 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, and 504.
  • Do not retry once the maximum attempt count has been reached.
  • Treat POST as 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-retryable POST, 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 example
<?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.