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 and HEAD are safe methods and are normally retryable when the endpoint follows HTTP semantics. PUT and DELETE are not safe, but they are defined as idempotent: repeating the same intended request should leave the resource in the same final state. 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.

A Timeout Is Not A Deadline Budget

A timeout limits one attempt. A deadline limits the total time available to complete the operation, including every attempt, delay, DNS lookup, connection, TLS negotiation, body transfer, and local processing.

Suppose a web request has two seconds left before the application server gives up. Three attempts with an eight-second timeout each are meaningless. The outbound client should receive a timeout no greater than the remaining deadline, with room left to build and send the response.

PHP example
<?php

declare(strict_types=1);

function timeoutForRemainingBudget(int $remainingMs, int $reserveMs = 100): int
{
    return max(0, $remainingMs - $reserveMs);
}

foreach ([2000, 600, 80] as $remaining) {
    echo $remaining . 'ms => ' . timeoutForRemainingBudget($remaining) . 'ms' . PHP_EOL;
}

// Prints:
// 2000ms => 1900ms
// 600ms => 500ms
// 80ms => 0ms

When no usable budget remains, fail before starting another network call. A background worker may have a much longer deadline than a web request, but it still needs one so shutdown, deployment, and queue retry behavior remain controlled.

Pass deadlines through internal service calls where practical. Otherwise each layer may assume it owns the full original budget and create a request chain that cannot finish in time.

Connect, Read, And Total Limits Protect Different Phases

A connection timeout covers establishing a connection, although exact DNS and TLS behavior depends on the client and transport. A read timeout covers periods waiting for more response data. A total timeout caps the operation.

A short connect timeout prevents an unreachable host from consuming the whole request. A read timeout protects against a peer that accepts the connection and then stalls. A total timeout protects against slow progress that never triggers an idle read timeout.

Client libraries use different names and semantics. Verify whether a timeout is in seconds or milliseconds, whether 0 disables it, whether redirects share one total, and whether streamed bodies apply the same rules.

Do not copy timeout numbers between endpoints without context. An address autocomplete lookup, payment authorization, report export, and object-storage upload have different user expectations and payload sizes.

A Timeout Creates An Unknown Outcome

When a client times out, it knows that it did not receive a complete response. It may not know whether the server received and committed the request.

For a read, retrying may be harmless. For a command such as creating a payment, sending a message, or reserving stock, the server may have completed the side effect before the response was lost.

Use an idempotency key generated before the first attempt and reuse the same key for every retry. The server must scope it appropriately, compare request fingerprints, and store the resulting status and response atomically. A random new key on each retry defeats the mechanism.

If the provider does not offer idempotency for a side-effecting operation, an automatic retry may be unsafe. Reconciliation by a business identifier or a manual unknown-state workflow can be more correct than guessing.

HTTP Method Semantics Are A Starting Point

GET and HEAD are defined as safe methods because they request information rather than an intended state change. PUT and DELETE are idempotent by HTTP semantics but are not classified as safe. Repeating the same intended PUT or DELETE should leave the resource in the same final state.

Real implementations can violate those expectations. A DELETE handler that sends another cancellation email on every request is not operationally idempotent even if the resource remains deleted. A GET endpoint that starts an export is badly designed for retries and caching.

Classify the specific endpoint contract, not only the method name. Provider documentation may define additional retry rules, request IDs, or status-query endpoints.

Transport Errors Need Classification

An HTTP status proves a response was received. Transport failures happen before a complete response exists. Examples include DNS failure, refused connection, TLS certificate error, reset connection, connect timeout, and read timeout.

Some are transient; others are configuration or security failures. Do not retry a certificate validation failure, unsupported protocol, malformed URL, or permanent DNS name mistake in a tight loop. A reset connection or temporary name-resolution failure may deserve a limited retry for an idempotent operation.

Catch the HTTP client's specific exception types and preserve their category. Converting everything to RuntimeException('API failed') prevents retry logic and monitoring from distinguishing timeout, authentication, and malformed response failures.

Respect Retry-After Carefully

Retry-After may contain a delay in seconds or an HTTP date. It commonly appears with 429 Too Many Requests and 503 Service Unavailable.

Treat the value as guidance within your own maximum delay and deadline. A server asking a user-facing request to wait 120 seconds should not make a PHP worker sleep for two minutes. Return a temporary response, queue the work, or let a later job retry.

PHP example
<?php

declare(strict_types=1);

function retryAfterDelaySeconds(string $value, DateTimeImmutable $now): ?int
{
    $value = trim($value);

    if ($value !== '' && ctype_digit($value)) {
        return (int) $value;
    }

    try {
        $date = new DateTimeImmutable($value);
    } catch (Exception) {
        return null;
    }

    return max(0, $date->getTimestamp() - $now->getTimestamp());
}

$now = new DateTimeImmutable('2026-06-10T10:00:00Z');
echo retryAfterDelaySeconds('30', $now) . PHP_EOL;
echo retryAfterDelaySeconds('Wed, 10 Jun 2026 10:01:00 GMT', $now) . PHP_EOL;

// Prints:
// 30
// 60

Invalid headers should not crash retry policy. Fall back to the client's bounded backoff or stop according to the operation.

Add Jitter To Backoff

Pure exponential backoff makes every worker that failed at the same time retry at the same times. Jitter spreads attempts across a window.

A full-jitter strategy chooses a random delay from zero to the exponential cap:

PHP example
<?php

declare(strict_types=1);

function retryCapMilliseconds(int $attempt): int
{
    return min(8000, 250 * (2 ** max(0, $attempt - 1)));
}

function fullJitterMilliseconds(int $attempt): int
{
    return random_int(0, retryCapMilliseconds($attempt));
}

foreach ([1, 2, 3, 4] as $attempt) {
    echo 'attempt ' . $attempt . ' cap ' . retryCapMilliseconds($attempt) . 'ms' . PHP_EOL;
}

// Prints the deterministic caps:
// attempt 1 cap 250ms
// attempt 2 cap 500ms
// attempt 3 cap 1000ms
// attempt 4 cap 2000ms

Tests should inject or isolate randomness rather than assert one random delay. The policy needs a maximum cap and maximum elapsed time as well as an attempt limit.

Retry Loops Must Rebuild Or Rewind Bodies Safely

A request body backed by a stream may be consumed on the first attempt. Retrying the same stream can send an empty or partial body unless the client rewinds or recreates it. Large uploads may be intentionally non-replayable.

Authentication signatures can also include timestamps or body hashes. Rebuild per-attempt headers as required while preserving the same idempotency key. Do not reuse an expired token or stale signature blindly.

Redirects add attempts too. Decide whether redirect time counts toward the same deadline and whether credentials may be forwarded to another host. Most integrations should restrict unexpected cross-host redirects.

Stop Retrying When The Caller Has Gone

If a browser disconnected or an upstream deadline expired, continuing synchronous downstream retries may waste capacity. Whether PHP can detect cancellation depends on the server/runtime, but application code should at least stop when its own deadline is exceeded.

For important work that must finish after the user leaves, accept the request, persist a job, and process it asynchronously. Return 202 Accepted with a status resource rather than holding a web worker through a long retry sequence.

Retries Differ From Circuit Breakers

A retry gives one operation another chance. A circuit breaker observes repeated failures across operations and temporarily prevents new calls. Backpressure and concurrency limits prevent too many calls from entering a dependency at once.

These controls complement each other. Retries without limits amplify an outage. A circuit breaker without a fallback still produces failures, but it produces them quickly and preserves worker capacity.

Keep retry policy close to the client integration, where method semantics, idempotency, exceptions, and provider headers are known. Avoid hidden retries at several layers, such as SDK, HTTP client, service mesh, and queue worker, because their multiplication can create far more attempts than intended.

Observe Attempts And Final Outcomes

Record an attempt as part of one logical operation rather than as an unrelated request. Useful fields include:

  • operation and dependency name
  • endpoint without secrets
  • attempt number and maximum
  • remaining deadline
  • connect and total timeout
  • status or transport error category
  • chosen delay and whether it came from Retry-After
  • idempotency key identifier in a safe form
  • final success, permanent failure, or exhausted-retry result

Metrics should separate first-attempt success from recovered success. A high recovered rate can hide a degrading dependency until retry traffic causes an incident.

Testing Retry Policy

Do not use real sleeps in unit tests. Inject a sleeper or return delay decisions separately. Use a controllable clock for deadlines and HTTP-date parsing. Use a scripted fake client that returns a sequence such as timeout, 503, then 200.

Test:

  • no retry for permanent client errors
  • no retry for unsafe commands without idempotency
  • same idempotency key on every attempt
  • body recreation or rewind
  • Retry-After seconds and date forms
  • maximum attempts and maximum elapsed time
  • deadline too short for another attempt
  • logging and metrics without credentials

Integration tests can verify the selected HTTP client's exception mapping and timeout units.

What To Check

Check every external call has explicit connection and total limits appropriate to its context.

Check retries share one total deadline and stop when insufficient budget remains.

Check timeout outcomes are treated as unknown for side-effecting commands.

Check method semantics, provider documentation, idempotency keys, and replayable bodies before retrying.

Check Retry-After, bounded exponential backoff, jitter, maximum attempts, and maximum elapsed time.

Check retries are not duplicated invisibly across client, SDK, proxy, and queue layers.

Check attempt metrics expose a dependency that succeeds only after retries.

What You Should Be Able To Do

After this lesson, you should be able to distinguish connection, read, total, and deadline limits; classify HTTP and transport failures; and decide whether an operation is safe to repeat.

You should also be able to reuse idempotency keys, parse and cap Retry-After, add jitter, preserve request bodies, stop within a deadline, avoid layered retry storms, and test retry policy without real network delays.

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);
    $repeatableMethod = in_array($method, ['GET', 'HEAD', 'PUT', 'DELETE'], true);
    $safePost = $method === 'POST' && $hasIdempotencyKey;

    if (!$repeatableMethod && !$safePost) {
        return ['retry' => false, 'delay_ms' => 0, 'reason' => 'operation is not safe to repeat automatically'];
    }

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

Practice: Enforce A Deadline Budget

Build a decision function that refuses a retry when the remaining operation deadline is too short.

Task

Write nextAttemptDecision() accepting:

  • remaining milliseconds
  • reserved response milliseconds
  • proposed backoff milliseconds
  • maximum per-attempt timeout milliseconds
  • current and maximum attempt numbers

Return whether to retry, the delay, and the timeout for the next attempt. The delay plus timeout plus reserve must fit inside the remaining budget. Demonstrate one allowed retry and one rejected retry.

Show solution

The next attempt receives only the budget left after delay and response reserve.

PHP example
<?php

declare(strict_types=1);

function nextAttemptDecision(
    int $remainingMs,
    int $reserveMs,
    int $backoffMs,
    int $maxAttemptTimeoutMs,
    int $attempt,
    int $maxAttempts,
): array {
    if ($attempt >= $maxAttempts) {
        return ['retry' => false, 'delay_ms' => 0, 'timeout_ms' => 0];
    }

    $availableForAttempt = $remainingMs - $reserveMs - $backoffMs;

    if ($availableForAttempt <= 0) {
        return ['retry' => false, 'delay_ms' => 0, 'timeout_ms' => 0];
    }

    return [
        'retry' => true,
        'delay_ms' => $backoffMs,
        'timeout_ms' => min($maxAttemptTimeoutMs, $availableForAttempt),
    ];
}

$allowed = nextAttemptDecision(2000, 100, 250, 1000, 1, 3);
$rejected = nextAttemptDecision(300, 100, 250, 1000, 1, 3);

printf("allowed: %s, delay %d, timeout %d\n", $allowed['retry'] ? 'yes' : 'no', $allowed['delay_ms'], $allowed['timeout_ms']);
printf("rejected: %s, delay %d, timeout %d\n", $rejected['retry'] ? 'yes' : 'no', $rejected['delay_ms'], $rejected['timeout_ms']);

// Prints:
// allowed: yes, delay 250, timeout 1000
// rejected: no, delay 0, timeout 0

The maximum per-attempt timeout is capped further when the overall deadline has less time available.

Practice: Parse Retry-After

Parse both legal Retry-After forms without relying on the real system clock.

Task

Write a function accepting the header string and an injected DateTimeImmutable $now. It should:

  • return integer delay seconds for a non-negative digit value
  • parse an HTTP date and return the positive difference from $now
  • return zero for a date in the past
  • return null for an invalid value

Demonstrate seconds, future date, past date, and invalid input. Explain why the caller must still cap the delay to its own deadline.

Show solution

Injecting the current time makes date-based behavior deterministic in tests.

PHP example
<?php

declare(strict_types=1);

function parseRetryAfter(string $value, DateTimeImmutable $now): ?int
{
    $value = trim($value);

    if ($value !== '' && ctype_digit($value)) {
        return (int) $value;
    }

    try {
        $date = new DateTimeImmutable($value);
    } catch (Exception) {
        return null;
    }

    return max(0, $date->getTimestamp() - $now->getTimestamp());
}

$now = new DateTimeImmutable('2026-06-10T10:00:00Z');

echo parseRetryAfter('45', $now) . PHP_EOL;
echo parseRetryAfter('Wed, 10 Jun 2026 10:01:30 GMT', $now) . PHP_EOL;
echo parseRetryAfter('Wed, 10 Jun 2026 09:00:00 GMT', $now) . PHP_EOL;
var_dump(parseRetryAfter('not-a-date', $now));

// Prints:
// 45
// 90
// 0
// NULL

A valid delay can still exceed the caller's remaining deadline or acceptable worker wait, so the retry policy must cap or reject it.