Web Development

Outbound HTTP Requests in PHP - Set Time Limits Before Adding Retries

Outbound HTTP Requests in PHP - Set Time Limits Before Adding Retries

A small PHP application can call a remote API in only a few lines. The harder question begins when the other system is slow, unreachable, or silent after receiving the request. How long should the application wait? Should it try again? If the first request timed out, did the remote service do nothing, or did its response merely fail to return?

There is no single timeout or retry count that answers every case. A weather lookup, payment request, webhook delivery, and background synchronization job have different costs and failure semantics. A useful design therefore starts with boundaries: limit each attempt, distinguish what actually failed, decide whether repetition is safe, and limit the complete operation. This article applies that model to PHP's cURL extension without claiming that a short helper can solve every distributed-systems problem.

A remote call creates an uncertain boundary

An outbound request crosses several components that the PHP process does not control. DNS resolution can stall, a TCP connection can fail, a TLS handshake can break, the server can return an error, or the response can disappear after the server has already changed its state. These outcomes may look similar to a user waiting for a page, but they are not interchangeable evidence.

The first defensive rule is simple: waiting forever is not a recovery strategy. The official libcurl documentation says that CURLOPT_TIMEOUT_MS defaults to zero, meaning no transfer timeout. A framework or SDK may supply its own defaults, but raw PHP cURL code should not assume that it does.

A timeout does not prove the remote operation failed. It proves only that the client stopped waiting under its configured rule. This distinction is especially important for a state-changing request. The remote server might commit an order or send a message just before the client loses the connection. A blind retry can then repeat the effect it was meant to rescue.

Connect time and transfer time answer different questions

libcurl exposes two useful limits:

  • CURLOPT_CONNECTTIMEOUT_MS limits the connection phase.
  • CURLOPT_TIMEOUT_MS limits the complete transfer attempt.

According to the libcurl connect-timeout documentation, the connection phase includes DNS resolution and the protocol handshakes and negotiations needed to establish the connection. Once connected, that limit no longer controls how long the response may take.

The total timeout is not added after the connect timeout. The connect phase is inside the total limit. If the connect limit is 1.5 seconds and the total limit is 5 seconds, the complete attempt cannot use 6.5 seconds: connection may use at most 1.5 seconds, while the whole transfer may use at most 5 seconds.

Numbers such as 1.5 and 5 seconds are examples, not recommended defaults. A background export may legitimately need minutes; an interactive page may have a much smaller latency budget. Values should come from the endpoint's documented behavior, observed latency, user-facing deadline, and the cost of abandoning work. A timeout set below normal latency creates self-inflicted failures, while a very large one can consume PHP workers long after the caller has stopped caring.

A transport error is not an HTTP response

PHP's curl_exec documentation makes an easily missed distinction. With CURLOPT_RETURNTRANSFER enabled, a transfer failure returns Boolean false. An HTTP response such as 404 is not a cURL transfer failure; the request can complete successfully at the transport level and still be unsuccessful for the application.

That gives the client at least three result classes:

  1. Transport failure: there is no usable HTTP response. Preserve curl_errno() and a bounded diagnostic message. The libcurl error list, for example, assigns code 28 to an operation timeout and separate codes to DNS, connection, TLS, send, and receive failures.
  2. HTTP response outside the accepted contract: a status such as 401, 404, 429, or 503 was received. These statuses do not all justify the same action.
  3. Accepted HTTP response: the status is allowed, but the application may still need to validate content type, size, JSON syntax, and required fields.

Collapsing all three into “API failed” removes information needed for recovery. Retrying invalid credentials will not repair them. Treating malformed JSON as a network timeout hides a contract problem. Treating every 2xx body as valid can move the failure deeper into the application.

Decide whether the operation is repeatable first

A retry policy needs two approvals: the failure must plausibly be temporary, and the operation must be safe to repeat. Checking only the first condition is how a resilience feature becomes a duplicate-action bug.

RFC 9110 defines idempotent methods as methods whose intended effect from multiple identical requests is the same as from one request. PUT, DELETE, and the safe methods are idempotent under HTTP semantics; POST is not inherently idempotent. The RFC permits automatic retry of idempotent requests after a connection failure and places a stronger restriction on automatic retry of non-idempotent requests.

Method names are still not magic. A poorly designed GET endpoint might trigger an action despite HTTP semantics, and a particular POST API may support safe repetition through a documented idempotency key. Before retrying a mutation, inspect the provider's contract: how is the key scoped, how long is it retained, and does the same key with a different body cause rejection? If those answers are absent, an ambiguous timeout should normally become a visible state for reconciliation, not an automatic second POST.

Conditions such as preconditions, stable operation identifiers, or a provider-supported idempotency key can make some operations conditionally repeatable. They reduce ambiguity only to the extent guaranteed by the receiving system. A random header that the server ignores provides no protection.

Retry a narrow set of temporary outcomes

For an operation already judged repeatable, temporary candidates may include selected DNS or connection failures, timeouts, and server responses that the API documents as retryable. The exact allowlist belongs to the integration, not to a universal snippet.

HTTP provides useful signals without promising that every server will use them. RFC 6585 defines 429 Too Many Requests and allows its response to include Retry-After. RFC 9110 defines that field as either an HTTP date or a number of seconds. A client that understands it can delay accordingly, subject to its own maximum wait and overall deadline. The server may omit the field, and a heavily loaded server is not required to send 429 at all.

Most ordinary 4xx responses should prompt inspection rather than repetition: malformed input, failed authentication, missing permission, or an absent resource usually needs a change. Some 5xx responses may be temporary, but retry safety still depends on the request semantics and provider documentation. Even a conventional list such as 408, 429, 500, 502, 503, and 504 is a starting point for classification, not permission to replay every request.

Bound retries with backoff, jitter, and one deadline

An immediate retry may succeed when a connection was briefly interrupted. Repeating immediately several times can also increase pressure on a struggling dependency. Exponential backoff spaces later attempts farther apart. Jitter adds random variation so that many clients do not all return at the same instant.

Backoff alone is not a bound. A policy also needs a maximum attempt count, a maximum delay, and preferably a deadline for the complete operation. Three attempts with a five-second timeout can already spend about fifteen seconds in transfers, plus sleep, DNS, and local processing. A per-attempt timeout therefore does not answer “how long can this job occupy a worker?”

The Everything curl retry guide makes this distinction explicit for the command-line tool: its per-transfer maximum and total retry-time cap are separate controls. Application code needs the same conceptual separation even when it implements the loop itself.

A bounded PHP example for GET requests

The following example intentionally supports only GET. It separates transport and HTTP results, retries a narrow set of temporary outcomes, uses full jitter, and stops against an overall deadline. Its values are illustrative and should be calibrated for the actual API.

<?php
function fetchGet(string $url): array
{
    $deadlineMs = (hrtime(true) / 1_000_000) + 12_000;
    $retryableCurl = [
        CURLE_COULDNT_RESOLVE_HOST,
        CURLE_COULDNT_CONNECT,
        CURLE_OPERATION_TIMEDOUT,
        CURLE_SEND_ERROR,
        CURLE_RECV_ERROR,
    ];
    $retryableHttp = [408, 429, 500, 502, 503, 504];

    for ($attempt = 1; $attempt <= 3; $attempt++) {
        $remainingMs = (int) floor($deadlineMs - (hrtime(true) / 1_000_000));
        if ($remainingMs <= 0) {
            throw new RuntimeException('HTTP operation deadline exceeded');
        }

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT_MS => min(1500, $remainingMs),
            CURLOPT_TIMEOUT_MS => min(5000, $remainingMs),
            CURLOPT_HTTPHEADER => ['Accept: application/json'],
        ]);

        $body = curl_exec($ch);
        $errno = curl_errno($ch);
        $error = curl_error($ch);
        $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $duration = curl_getinfo($ch, CURLINFO_TOTAL_TIME);
        curl_close($ch);

        if ($body !== false && $status >= 200 && $status < 300) {
            return ['status' => $status, 'body' => $body, 'seconds' => $duration];
        }

        $mayRetry = $body === false
            ? in_array($errno, $retryableCurl, true)
            : in_array($status, $retryableHttp, true);

        if (!$mayRetry || $attempt === 3) {
            $detail = $body === false ? "cURL {$errno}: {$error}" : "HTTP {$status}";
            throw new RuntimeException("Remote request failed: {$detail}");
        }

        $capMs = min(2000, 250 * (2 ** ($attempt - 1)));
        $sleepMs = random_int(0, $capMs);
        $remainingMs = (int) floor($deadlineMs - (hrtime(true) / 1_000_000));
        if ($sleepMs >= $remainingMs) {
            throw new RuntimeException('No time remains for another attempt');
        }
        usleep($sleepMs * 1000);
    }

    throw new LogicException('Unreachable retry state');
}

This is a teaching example, not a drop-in HTTP client. It does not parse Retry-After, limit response size, validate the URL, decode JSON, handle cancellation from an upstream request, or expose metrics. It also assumes that the target GET follows safe HTTP semantics. A maintained SDK may already implement provider-specific retry and idempotency rules more accurately; duplicating its retry layer can multiply attempts unexpectedly.

For a POST or another business mutation, the safer structure is different: establish the operation's idempotency protocol first, persist enough local state to reconcile an ambiguous result, and only then enable automatic retry for explicitly approved outcomes.

Make failure visible without leaking secrets

Retries can make a system quieter for users while making its dependencies harder to understand. Record enough structured context to answer what happened: dependency name, operation name, attempt number, final outcome class, HTTP status or cURL error code, elapsed time, and a correlation or operation identifier. Do not put access tokens, authorization headers, complete sensitive payloads, or unrestricted response bodies into routine logs.

Useful measurements include request count, latency, timeout count, retries attempted, retries exhausted, and responses by coarse status class. These do not prove why a dependency failed, but they reveal whether the selected limits match reality. If almost every successful call requires the final attempt, the retry loop may be masking a bad timeout or an unhealthy service.

Tests should cover more than a happy 200: delayed connection, delayed response, DNS failure, TLS failure, malformed content, permanent 4xx, temporary response with and without Retry-After, exhausted deadline, and an ambiguous mutation outcome. A fake server or controlled test double is safer and more reproducible than waiting for a production dependency to fail on cue.

Conclusion

Reliable outbound HTTP is not created by adding a loop around curl_exec. It comes from a sequence of narrower decisions: set a connect limit and a total attempt limit, distinguish transport failure from an HTTP response, verify that the operation is repeatable, retry only approved temporary outcomes, and stop the complete operation within a known budget.

These controls cannot remove uncertainty after a timed-out mutation. They can keep that uncertainty from being hidden behind unlimited waiting or automatic duplicate requests. The most useful next question for any integration is therefore not “How many retries should it have?” but “Which outcome can this application safely recognize, repeat, or reconcile?”

References