In Guzzle 7, what does the http_errors option control, and which exceptions do a 404, a 503 and a timed-out request raise?
answer
- status errors versus network errors
- http_errors defaults to true
- ClientException 4xx, ServerException 5xx
- ConnectException is not a RequestException
- getResponse on BadResponseException
basics
~20 sWith 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
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
Recall that 4xx throws ClientException and 5xx ServerException by default, and that http_errors false turns that off.
Draw the exception tree, place ConnectException outside RequestException, and explain that http_errors lives in middleware installed by HandlerStack::create().
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.
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.