Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Handle PHP HTTP failures in three separate paths: an HTTP response with a 4xx or 5xx status, a transport failure such as DNS or connection timeout, and a response-decoding failure. The response path gives you a status, headers, and usually a body; a transport failure may give you none of those. Your client library determines whether an unsuccessful status is returned normally or converted into an exception.

This distinction prevents two common bugs: treating a successful transfer as a successful request, and catching every exception as if it meant the same thing. The examples below show native HTTP streams, cURL, Guzzle, and Symfony HttpClient patterns that preserve useful diagnostics while avoiding unsafe retries.

Three different failures require three different handlers

HTTP status failure

The server received the request and sent an HTTP response, but your application considers the status unsuccessful. A 404 proves that a response arrived; it does not prove that the requested resource exists. A 400 may include a validation message, while a 401 or 403 may require credentials or permission changes. Preserve the status, headers, and body before deciding what to return to your caller.

Transport failure

DNS resolution, connection refusal, TLS negotiation, a socket timeout, or a broken connection can occur before a usable HTTP response exists. In that case there is no reliable server status or error body to inspect. Log the underlying exception and apply a bounded retry policy only when repeating the operation is safe.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Decoding or parsing failure

A response can arrive successfully and still fail when your code expects JSON, XML, or another representation. A malformed JSON document, an unexpected content type, or invalid character data is a separate failure from both the HTTP status and the network transfer. Keep the raw body available for diagnostics, but do not expose sensitive response content to end users.

Native PHP HTTP streams

The HTTP stream wrapper does not give you a convenient exception model. By default, its ignore_errors context option is false, so failure-status responses may prevent file_get_contents() from returning the body. Set it to true when an error body is part of your application logic, then inspect the response metadata yourself. See the HTTP context options documentation.

<?php
$url = 'https://api.example.test/orders/123';
$context = stream_context_create([
    'http' => [
        'method' => 'GET',
        'ignore_errors' => true,
        'timeout' => 10,
        'header' => "Accept: application/jsonrn",
    ],
]);

$body = file_get_contents($url, false, $context);
$headers = $http_response_header ?? [];

if ($body === false) {
    throw new RuntimeException('No HTTP response was available');
}

$status = null;
foreach ($headers as $header) {
    if (preg_match('#^HTTP/S+s+(d{3})#', $header, $m)) {
        $status = (int) $m[1];
    }
}

if ($status === null) {
    throw new RuntimeException('The response status could not be determined');
}

if ($status < 200 || $status >= 300) {
    error_log("HTTP $status: " . substr($body, 0, 2000));
    // Map or decode the error body here; do not silently treat it as success.
}

The HTTP wrapper manual notes that response headers remain available through $http_response_header even when the content read fails for 4xx or 5xx responses. Redirects can produce several status lines, so select the final relevant response rather than assuming the first line is the result. On PHP versions where the header variable is deprecated, use the replacement response-header API documented for that version.

cURL: separate transfer success from HTTP success

PHP’s curl_exec() reports whether the transfer itself worked. It does not treat a 404 or 500 as a transfer failure. The PHP manual states: “Note that response status codes which indicate errors (such as 404 Not found) are not regarded as failure. curl_getinfo() can be used to check for these.” Read the curl_exec manual for the version you run.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
<?php
$ch = curl_init('https://api.example.test/orders/123');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 20,
    CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);

$body = curl_exec($ch);
if ($body === false) {
    $message = curl_error($ch);
    $number = curl_errno($ch);
    curl_close($ch);
    throw new RuntimeException("cURL transfer failed ($number): $message");
}

$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);

if ($status < 200 || $status >= 300) {
    error_log("HTTP $status ($contentType): " . substr($body, 0, 2000));
}

Do not write if (!$body) as your failure test: an empty but valid response body is falsey in PHP. Test explicitly for false, then inspect CURLINFO_HTTP_CODE. A nonzero status is still an HTTP response, whereas curl_exec() === false means cURL could not complete the transfer.

Guzzle: configure HTTP exceptions deliberately

Guzzle’s behavior depends on the http_errors request option. With it enabled, a 4xx response is represented by a ClientException and a 5xx response by a server-side exception; networking failures use ConnectException. These classes share Guzzle’s transfer-exception hierarchy. Check the documentation for your installed major version before relying on exact defaults or class names; the stable quickstart is at Guzzle documentation.

Inspect statuses without exceptions

<?php
use GuzzleHttpClient;

$client = new Client(['http_errors' => false, 'timeout' => 15]);
try {
    $response = $client->request('GET', 'https://api.example.test/orders/123');
    $status = $response->getStatusCode();
    $body = (string) $response->getBody();
    $headers = $response->getHeaders();

    if ($status < 200 || $status >= 300) {
        // Decode a documented error schema, or map this status to your API error.
        error_log("HTTP $status: " . substr($body, 0, 2000));
    }
} catch (GuzzleHttpExceptionConnectException $e) {
    // DNS, connection, TLS, or timeout failure: no response is guaranteed.
    error_log($e->getMessage());
}

Catch an HTTP exception while retaining its response

<?php
use GuzzleHttpClient;
use GuzzleHttpExceptionClientException;
use GuzzleHttpExceptionServerException;
use GuzzleHttpExceptionConnectException;

$client = new Client(['http_errors' => true]);
try {
    $response = $client->get('https://api.example.test/orders/123');
} catch (ClientException|ServerException $e) {
    $response = $e->getResponse();
    $status = $response ? $response->getStatusCode() : null;
    $body = $response ? (string) $response->getBody() : '';
    error_log("HTTP exception $status: " . substr($body, 0, 2000));
} catch (ConnectException $e) {
    error_log('Transport failure: ' . $e->getMessage());
}

Do not catch only a broad exception and return an empty array. That erases whether the server rejected a request, the network failed, or a response body was malformed. If your code needs one uniform result type, include fields such as kind, status, headers, body, and retryable.

Symfony HttpClient: lazy responses and explicit status handling

Symfony defines separate interfaces for HTTP, transport, and decoding failures. HttpExceptionInterface represents an unhandled 3xx–5xx response, TransportExceptionInterface represents lower-level failures, and DecodingExceptionInterface represents content that cannot be decoded as requested. On a 300–599 response, getHeaders(), getContent(), and toArray() throw unless you pass false.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Handle the status yourself

<?php
use SymfonyComponentHttpClientHttpClient;
use SymfonyContractsHttpClientExceptionDecodingExceptionInterface;
use SymfonyContractsHttpClientExceptionTransportExceptionInterface;

$client = HttpClient::create(['timeout' => 15]);
try {
    $response = $client->request('GET', 'https://api.example.test/orders/123');
    $status = $response->getStatusCode();
    $headers = $response->getHeaders(false);
    $body = $response->getContent(false);

    if ($status < 200 || $status >= 300) {
        error_log("HTTP $status: " . substr($body, 0, 2000));
    }

    if (($headers['content-type'][0] ?? '') === 'application/json') {
        try {
            $data = $response->toArray(false);
        } catch (DecodingExceptionInterface $e) {
            error_log('Invalid JSON response: ' . $e->getMessage());
        }
    }
} catch (TransportExceptionInterface $e) {
    error_log('Transport failure: ' . $e->getMessage());
}

Symfony responses are lazy: a network error may occur during request() or later when a response method forces the transfer. Keep both creation and response access inside the transport-exception handling scope. If you prefer exceptions for statuses, call getContent() or toArray() without false and catch HttpExceptionInterface, then obtain the response details according to your installed Symfony version.

Retry decisions: status, safety, and library policy

An error is not automatically retryable. A malformed request, invalid credentials, or missing permission needs a corrected request, not another identical one. A temporary overload or rate limit may recover, but repeating a non-idempotent POST can create duplicate work or charges.

  • Classify the operation: GET and other idempotent operations are generally safer to repeat than a request that creates or transfers money.
  • Use bounded exponential backoff with jitter, and stop after a small, explicit attempt limit.
  • Honor server guidance such as Retry-After when present and parse it safely.
  • Make writes idempotent with an idempotency key when the API supports one.
  • Know your client’s defaults. Symfony’s current documentation describes up to three retries with exponential delay for selected statuses, with eligibility varying by HTTP method. Do not assume that policy applies to Guzzle, cURL, or streams.

Diagnostics and secure error handling

Record the request method, sanitized URL, status (when available), elapsed time, correlation ID, and a truncated or redacted response body. Never log authorization headers, cookies, access tokens, or full payment data. Preserve the original exception as the cause when translating it into a domain exception. Return stable application errors to callers instead of leaking stack traces or upstream secrets.

Common problems and fixes

“My 404 did not throw.”

That is expected with cURL and with clients configured to return non-success responses. Check the status explicitly. In Guzzle, inspect http_errors; in Symfony, pass false to response accessors when you want manual handling.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

“I cannot read the JSON error body.”

Ensure the client exposes the response instead of discarding it. For streams set ignore_errors => true; for Symfony use getContent(false) or toArray(false); for Guzzle disable HTTP exceptions or read the response from the caught exception.

“The catch block never runs for a timeout.”

With lazy clients, the transfer may not start until a response method is called. Put those calls inside the same try block. With cURL, check curl_exec() === false and record curl_errno() and curl_error().

“Retries made the incident worse.”

Remove automatic retries for non-idempotent operations unless the API provides idempotency protection. Add a maximum attempt count, backoff, and logging that identifies each attempt.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

If your PHP service needs reliable screenshots of a URL rather than a browser automation stack, ScreenshotNeo provides a GET-based screenshot API. It accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be disabled. Only clean shots are billed: bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers identify the page verdict and billing result. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use the same HTTP error distinctions in your PHP caller: test for a transport failure, inspect the HTTP status, and preserve the body and X-Page-Verdict/X-Billed headers for diagnostics.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

See the ScreenshotNeo API documentation for options and response details. A free plan includes 1,000 shots per month with no card; paid plans start at $5 for 3,000 shots. Sign up for the free plan.

FAQ

Should every non-2xx response become an exception?

No. Choose behavior that matches your application. Exceptions can simplify failure flow, while explicit status handling makes it easier to process documented error bodies and expected statuses such as 404.

Can I retry a 500 response?

Sometimes, but only when the operation is safe to repeat and your retry policy has limits and backoff. A 500 does not prove that a write was not applied.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

What should an API wrapper return to its caller?

Return a structured result or domain exception that preserves the status when a response exists, distinguishes transport and decoding failures, and includes a redacted diagnostic context.

Frequently Asked Questions

Should every non-2xx response become an exception?

No. Choose behavior that matches your application. Exceptions can simplify failure flow, while explicit status handling makes it easier to process documented error bodies and expected statuses such as 404.

Can I retry a 500 response?

Sometimes, but only when the operation is safe to repeat and your retry policy has limits and backoff. A 500 does not prove that a write was not applied.

What should an API wrapper return to its caller?

Return a structured result or domain exception that preserves the status when a response exists, distinguishes transport and decoding failures, and includes a redacted diagnostic context.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The Bottom Line

Check HTTP status separately from transfer success, preserve response details, and let your chosen client’s documented exception and retry behavior guide the catch blocks.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.