Error handling for Laravel's HTTP facade

Published on

I've been using Laravel's HTTP facade extensively since it was introduced in Laravel 7 back in 2020. The HTTP facade is a user-friendly wrapper around Guzzle that also makes it easy to write unit tests by calling Http::fake().

The only thing that I've had trouble with when using the HTTP facade is error handling. Sometimes it throws an exception, sometimes it doesn't, and depending on if you use retry() this behavior changes.

This is the approach I've settled on that allows me to properly handle errors when using the HTTP facade:

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;

try {
    $response = Http::acceptJson()
        ->connectTimeout(5)
        ->timeout(10)
        ->throw()
        // ->retry(times: 2, sleepMilliseconds: 500)
        ->get('https://example.com');
} catch (ConnectionException $e) {
    // handle the error (no response is available)
} catch (RequestException $e) {
    // handle the error (details are in "$e->response")
}

// handle the response (status code >= 200 && <= 399)

The code above catches all errors the HTTP facade can throw in a consistent way.

Catching the Connection​Exception

The HTTP facade always throws a Connection​Exception when it fails to connect. Failures to connect are inevitable, for example when DNS has a hiccup or when the remote server is down for maintenance.

The connect​Timeout() method sets how many seconds the HTTP facade should attempt to connect to the remote server. If this time is exceeded and a connection still hasn't been made then a Connection​Exception is thrown.

The timeout() method sets the total allowed duration of the request (including the connection). If this time is exceeded a Connection​Exception is also thrown.

Catching the Request​Exception

The Request​Exception is:

  • Not thrown by default when a response has a 4xx/5xx status code
  • Thrown if throw() is used, and the response has a 4xx/5xx status code
  • Thrown if retry() is used, and the response has a 4xx/5xx status code
  • Not thrown retry(throw: false) is used

The key point here is that if you don't use throw(), and you add retry() to existing code, you'll completely change how errors should be handled. To prevent this footgun I recommend always using throw(), that way exceptions are always thrown regardless of if you use retry() or not.

The retry method also has a parameter that disables throwing: retry(throw: false). I can never seem to remember that this parameter exists, so I prefer always using throw() instead.

HTTP requests and queued job timeouts

HTTP requests are usually made inside queued jobs. It is important to make sure the queued job doesn't time out, because then your error handling code won't get called (unless you also have that logic in the failed() method of your job).

To prevent queued jobs timing out, make sure that the sum of (HTTP timeout + sleep between retries) * amount of retries is not higher than your job timeout.

To keep things simple I generally prefer retrying my queued jobs and not using the retry() method on my HTTP requests, but this is personal preference.

Manually testing errors and timeouts

You can manually test how your code handles errors by calling these URLs:

  • Call 10.0.0.0 for a connection timeout
  • Call https://httpstat.us/200?sleep=15000 for a read timeout
  • Call https://httpstat.us/500 for a specific status code