skip to content

In Guzzle 7, what does the http_errors option control, and which exceptions do a 404, a 503 and a timed-out request raise?

level: middleimportance: must knowfreq 55%

answer

  1. status errors versus network errors
  2. http_errors defaults to true
  3. ClientException 4xx, ServerException 5xx
  4. ConnectException is not a RequestException
  5. getResponse on BadResponseException

basics

~20 s

With http_errors true (the default), a 404 throws ClientException and a 503 ServerException, both BadResponseExceptions carrying the response. A timeout or refused connection throws ConnectException, which since Guzzle 7 extends TransferException, not RequestException, and has no response.

solid answer

~40 s

`http_errors` decides whether a 4xx or 5xx response becomes an exception. It is `true` by default, applied by the `httpErrors` middleware that `HandlerStack::create()` installs. Then a 404 throws `ClientException` and a 503 `ServerException`. Both extend `BadResponseException`, a `RequestException`, so `getResponse()` gives you the status and body. With `http_errors => false` you get a normal response and check `getStatusCode()` yourself. Network failures are a separate branch: a refused connection, a DNS failure or a timeout on the cURL handler throws `ConnectException`. Since Guzzle 7.0 that class extends `TransferException` directly and has no response, so a `catch (RequestException $e)` block does **not** catch a timeout. To catch everything, catch `TransferException` or the `GuzzleException` interface. `sendRequest()`, the PSR-18 method, always behaves as if `http_errors` were false.

code

php · 20 lines
php
<?php
declare(strict_types=1);

use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\ServerException;

$client = new Client(['base_uri' => 'https://fx-a.example.test/', 'timeout' => 3]);

try {
    $response = $client->request('GET', 'rates', ['query' => ['base' => 'EUR']]);
} catch (ConnectException $e) {
    $response = null; // timeout, DNS or refused connection: no response exists
} catch (ServerException $e) {
    $response = null; // 5xx: provider trouble, fail over
} catch (ClientException $e) {
    $status = $e->getResponse()->getStatusCode(); // 4xx: our request or our key
    throw new RuntimeException("Rates request rejected with $status", previous: $e);
}

go deeper

for a junior

Recall that 4xx throws ClientException and 5xx ServerException by default, and that http_errors false turns that off.

for a middle

Draw the exception tree, place ConnectException outside RequestException, and explain that http_errors lives in middleware installed by HandlerStack::create().

for a senior

Map each branch to an action (fail over, retry, alert), catch at the right level, and check Guzzle 6 era catch blocks that no longer see timeouts.

for a principal

Define one error vocabulary for outbound calls so status, network and configuration failures reach monitoring distinctly instead of collapsing into one generic exception.

## Two families of failure When an HTTP call fails, either the server **answered** with an error status, or **no answer** arrived at all. Guzzle models these as two branches of one exception tree, all rooted in `\RuntimeException`: ``` TransferException (implements GuzzleException) ├── ConnectException (implements PSR-18 NetworkExceptionInterface) └── RequestException (implements PSR-18 RequestExceptionInterface) ├── BadResponseException │ ├── ClientException (4xx) │ └── ServerException (5xx) └── TooManyRedirectsException ``` ## What http_errors does `http_errors` is a request option, `true` by default in the client's defaults. It is enforced by `Middleware::httpErrors()`, which `HandlerStack::create()` adds (a client built without a `handler` gets that stack automatically). On the way back up the stack, the middleware looks at the status code: - **400-499**: rejects the promise with `ClientException`; - **500-599**: rejects with `ServerException`; - anything else: passes the response through. The exception message includes the method, URI, status and a truncated summary of the body. Because both classes extend `BadResponseException`, `getResponse()` always returns a response, and `getRequest()` gives the request that was sent. With `'http_errors' => false`, every response is returned normally and your code checks `getStatusCode()`. That is often cleaner when a 404 is an expected outcome, such as "no rate for this currency pair". The option does nothing if the handler stack lacks the middleware. A client built with a bare handler, such as `new Client(['handler' => $mock])` in a test, never throws on 4xx or 5xx at all. ## Network failures: ConnectException With the cURL handler, Guzzle maps a set of cURL errors to `ConnectException`: an operation timeout, an unresolved host, a refused connection, an SSL connect error and an empty reply. So a request that exceeds `timeout` or `connect_timeout` throws `ConnectException`, not a `RequestException`. Other transfer failures become a plain `RequestException`. The Guzzle 7.0 upgrade notes record two changes that trip people up: 1. `ConnectException` now extends `TransferException`, no longer `RequestException`. 2. `ConnectException::getResponse()` and `hasResponse()` were removed, because there is no response. Code written for Guzzle 6 that did `catch (RequestException $e)` to handle "anything went wrong" silently stopped catching timeouts after the upgrade. ## Catching at the right level | You want to handle | Catch | |---|---| | a specific 4xx, such as 404 or 422 | `ClientException`, then inspect `getResponse()->getStatusCode()` | | upstream outages (5xx) | `ServerException` | | timeouts and unreachable hosts | `ConnectException` | | any error status with a response | `BadResponseException` | | every Guzzle failure | `TransferException`, or the `GuzzleException` interface | Order `catch` blocks from specific to general. `GuzzleException` also covers Guzzle's own `InvalidArgumentException`, which is thrown for bad options, for example a `json` value that cannot be encoded. ## Reading the details off an exception Every `RequestException` and `ConnectException` exposes `getRequest()`, the request that was actually sent, after `base_uri` resolution and middleware. `getHandlerContext()` returns what the handler knew; with the cURL handler that includes `errno` and `error`, and the exception message itself reads like `cURL error 28: ...` for a timeout. `BadResponseException` adds `getResponse()`, which is guaranteed non-null, so the status code and body of an error response are always available for logging. Read the body with care: it is a stream, and the exception message already contains only a **truncated** summary of it. Log the status and a bounded slice of the body, not the whole payload, which may be large or contain data you should not store. ## sendRequest() and PSR-18 `Client` also implements PSR-18's client interface. Its `sendRequest(RequestInterface $request)` forces `http_errors` and `allow_redirects` to `false`, as the standard requires: every response, including 404 and 500, is returned, and only transfer problems throw. Code written against the PSR-18 interface must therefore check status codes itself. ## Scenario: three exchange-rate providers For a rates lookup, a sensible mapping is: - `ConnectException`: the provider is down or slow, so try the next provider; - `ServerException`: the same, and possibly retry; - `ClientException` with 401 or 403: a configuration bug (a bad API key), so alert rather than fail over; - any other `ClientException`: a bad request built by our code, so log it with the response body.

  • How does Client::sendRequest() treat a 404 compared with request()?
    `sendRequest()` is the PSR-18 method, and Guzzle forces `http_errors` and `allow_redirects` to `false` for it. A 404 comes back as a normal response, and only transfer failures throw. `request()` uses the client defaults, so with `http_errors` true the same 404 throws `ClientException`.
  • Why does a Guzzle client built with a bare MockHandler not throw on a queued 500 response?
    `http_errors` is enforced by the `httpErrors` middleware, which lives in the stack built by `HandlerStack::create()`. Passing a MockHandler directly as `handler` skips that stack, so no middleware converts the 500 into `ServerException`. Wrap the mock with `HandlerStack::create($mock)` to test the real error behaviour.

saying these in an interview costs you the question

  • catch (RequestException $e) in Guzzle 7 also catches timeouts.
  • A ConnectException lets you read the response with getResponse().
  • With http_errors false, Guzzle also stops throwing on timeouts.
  • Guzzle's sendRequest() throws ClientException on a 404 like request() does.
  • A 503 response raises ConnectException because the server is unavailable.