A modern REST API in Laravel 5 Part 3: Error handling

Handle exceptions the API way

Posted by Esben Petersen on January 14, 2017

tl;dr

Install optimus/heimdal to get an extensive exception handler for your Laravel API with Sentry integration.

The full code to this article can be found here: larapi-series-part-3

Introduction

Error handling is often an overlooked element of development, unfortunately. Luckily, this article will take you through the basics of API error handling. We will also install the API exception handler for Laravel Heimdal which will quickly give us an awesome API exception handler with Sentry integration out of the box.

Agenda

In this article I will take you through...

  1. A general introduction to how to do error handling in an API
  2. Show you how to customize how different errors are formatted using Heimdal
  3. Show you how to log your errors in external trackers using reporters

Let's do this.

API error handling crash course

If you are already an avid Laravel user you will now that the classic way Laravel handles errors are by rendering a certain view based on the error severity. A 404 exeption? Show the 404.blade.php page. A 5xx error? Show the stack trace exception page in development mode and a production message in production environments.

This is all great but the way we do it in APIs is a bit different. You will soon discover that status codes have great meaning when dealing with APIs. The clients that consume our API will most likely run behaviour based on the status code returned by our API. Imagine a user tries to submit a form but Laravel throws a validation error (status code 422). The client will start parsing the response by reading that this is a 422 response. Therefore the client knows this is an error caused by the data sent to the API (because 4xx errors are client errors, while 5xx errors are caused by the server). A 422 typically means a validation error, so we take the response (probably validation error messages) and parse them through our validation error flow (show the validation error messages to the user).

User ------> Submits form ------> Data ------> API
 ^                                              |
 |                                              v
Fix error                                 Validation error
 ^                                              |
 |                                              v
Show error to user <---- Client <----- 422 response

Notice the difference here is that normally the user sees a 404 page or similar and determines the corresponding action by reading the view. When consuming APIs it is typically the computer that has to "see" the response and determine the corresponding action. If we showed a 404 page to the computer how would it know how to react? This is why getting the status codes right is so important.

User --------> Request resource --------> API
  ^                                        |
  |                                        v
Login                                 Unauthorized User
  ^                                        |
  |                                        v
Redirect to login <---- Client <----- 401 response

So what are some typical uses of HTTP statuses in APIs? Look no further than the table below.

Code Name What does it mean?
401 Unauthorized You are not logged in, e.g. using a valid access token
403 Forbidden You are authenticated but do not have access to what you are trying to do
404 Not found The resource you are requesting does not exist
405 Method not allowed The request type is not allowed, e.g. /users is a resource and POST /users is a valid action but PUT /users is not.
422 Unprocessable entity The request and the format is valid, however the request was unable to process. For instance when sent data does not pass validation tests.
500 Server error An error occured on the server which was not the consumer's fault.

This was by no means an exhaustive list. There are more status codes but these were all general ones to give you an idea of what you typically work with. Curious for more? Here is a great overview of HTTP status codes.

So now that we know what status codes to use, how should we go about formatting our response? Well, there are a lot of opinions on that. Many times it could just be an empty response.

HTTP/1.0 401 Unauthorized
Content-Type: application/json
{}

HTTP/1.0 403 Forbidden
Content-Type: application/json
{}

HTTP/1.0 405 Method not allowed
Content-Type: application/json
{}

Albeit not very helpful these are all valid responses. The client should be able to perform a corresponding action based on these responses, e.g. redirection.

At Traede we have access control using users, roles and permissions. Sometimes it is beneficial to show an user an action they are not allowed to perform. In such cases when they try to perform the action we will display a modal saying "You do not have access to viewing this customer's orders" or similar. To actually know what the user is not allowed to do we have to get the missing permissions from the request. Therefore, our 403 responses are formatted somewhat like this.

HTTP/1.0 403 Forbidden
Content-Type: application/json
{"error":true","missing_permissions":[{"permission":"orders:read","description":"customer's orders"}]}

So now our client has some useful information that it can display the user. So maybe, if this was an employee with limited access he can request access from someone who can give it to him.

Standardizing error responses with JSON API

The JSON API specification is one of several specifications discussing a standardized API design. Other examples include Microsoft's API guidelines and Heroku's HTTP API design. For the remainder of this article we will focus solely on JSON API. Not saying this is the "best".

The only requirement for SON API errors is that each object is in an array keyed by errors. Then there is a list of members you can put in each error object. None are required. You can see the exhaustive list here.

As an example imagine we try to create an user using POST /users. Let us say two validation errors occur: (1) the email is not an valid email and the password is not long enough. Using JSON API we could return this using this JSON object.

{
  "errors": [
    {
      "status": "422",
      "code": "110001",
      "title": "Validation error",
      "detail": "The email esben@petersendk is not an valid email."
    },
    {
      "status": "422",
      "code": "110002",
      "title": "Validation error",
      "detail": "The password has to be at least 8 characters long, you entered 7."
    }
  ]
}
HTTP/1.0 422 Unprocessable entity
Content-Type: application/json
{"errors":[{"status":"422","code":"110001","title":"Validation error","detail":"The email esben@petersendk is not an valid email."},{"status":"422","code":"110002","title":"Validation error","detail":"The password has to be at least 8 characters long, you entered 7."}]}

The client can easily display these to the user so that inputs can be changed.

Alright, this was a crash course to error handling. Let us look at some implementation!

Implementing Heimdal, the API exception handler for Laravel

At Traede we use Heimdal an API exception handler for APIs. It is easily installable using the guide in the README. The rest of this guide will assume you have installed it. PRO tip: My Laravel API fork already comes with Heimdal installed.

Alright, so Heimdal is installed and the config file optimus.heimdal.php has been published to our configuration directory. It already comes with sensible defaults as how to format ones errors. Let us take a look.

'formatters' => [
    SymfonyException\UnprocessableEntityHttpException::class => Formatters\UnprocessableEntityHttpExceptionFormatter::class,
    SymfonyException\HttpException::class => Formatters\HttpExceptionFormatter::class,
    Exception::class => Formatters\ExceptionFormatter::class,
],

So the way this works is that the higher the exception is, the higher the priority. So if an UnprocessableEntityHttpException (validation error) is thrown then it will be formatted using the UnprocessableEntityHttpExceptionFormatter. However, if an UnauthorizedHttpException is thrown there is no special formatter so it will be passed down through the formatters array until it hits a relevant formatter.

The UnauthorizedHttpException is a Symfony Http Exception is a subclass of HttpException and will therefore be caught by this line.

SymfonyException\HttpException::class => Formatters\HttpExceptionFormatter::class,

Let us assume the error that occurs is an server error (500). Imagine PHP throws an InvalidArgumentException. This is not a subclass of HttpException but is a subclass of Exception and will therefore be caught by the last line.

Exception::class => Formatters\ExceptionFormatter::class

So it will be formatted using ExceptionFormatter. Let us take a quick look at what it does.

<?php

namespace Optimus\Heimdal\Formatters;

use Exception;
use Illuminate\Http\JsonResponse;
use Optimus\Heimdal\Formatters\BaseFormatter;

class ExceptionFormatter extends BaseFormatter
{
    public function format(JsonResponse $response, Exception $e, array $reporterResponses)
    {
        $response->setStatusCode(500);
        $data = $response->getData(true);

        if ($this->debug) {
            $data = array_merge($data, [
                'code'   => $e->getCode(),
                'message'   => $e->getMessage(),
                'exception' => (string) $e,
                'line'   => $e->getLine(),
                'file'   => $e->getFile()
            ]);
        } else {
            $data['message'] = $this->config['server_error_production'];
        }

        $response->setData($data);
    }
}

Alright so when we are working in a development environment the returned error will just be the information available in the Exception: line number, file and so forth. When we are in a production environment we do not wish to display this kind of information to the user so we just return a special Heimdal configuration key server_error_production. This defaults to "An error occurred".

Debug environment

HTTP/1.0 500 Internal server error
Content-Type: application/json
{"status":"error","code":0,"message":"","exception":"InvalidArgumentException in [stack trace]","line":4,"file":"[file]"}

Production environment

HTTP/1.0 500 Internal server error
Content-Type: application/json
{"message":"An error occurred"}

Alright now, what about the HttpExceptionFormatter?

<?php

namespace Optimus\Heimdal\Formatters;

use Exception;
use Illuminate\Http\JsonResponse;
use Optimus\Heimdal\Formatters\ExceptionFormatter;

class HttpExceptionFormatter extends ExceptionFormatter
{
    public function format(JsonResponse $response, Exception $e, array $reporterResponses)
    {
        parent::format($response, $e, $reporterResponses);

        $response->setStatusCode($e->getStatusCode());
    }
}

Aha, so the base HttpExceptionFormatter is just adding the HTTP status code to the response but is otherwise exactly the same as ExceptionFormatter. Awesomesauce.

Let us try to add our own formatter. According to the HTTP specification a 401 response should include a challenge in the WWW-Authenticate header. This is currently not added by the Heimdal library (it will after this article), so let us create the formatter.

<?php

namespace Infrastructure\Exceptions;

use Exception;
use Illuminate\Http\JsonResponse;
use Optimus\Heimdal\Formatters\HttpExceptionFormatter;

class UnauthorizedHttpExceptionFormatter extends HttpExceptionFormatter
{
    public function format(JsonResponse $response, Exception $e, array $reporterResponses)
    {
        parent::format($response, $e, $reporterResponses);

        $response->headers->set('WWW-Authenticate', $e->getHeaders()['WWW-Authenticate']);

        return $response;
    }
}

Symfony's HttpException contains an header array that contains an WWW-Authenticate entry for all UnauthorizedHttpException. Next, we add the formatter to config/optimus.heimdal.php.

'formatters' => [
    SymfonyException\UnprocessableEntityHttpException::class => Formatters\UnprocessableEntityHttpExceptionFormatter::class,
    SymfonyException\UnauthorizedHttpException::class => Infrastructure\Exceptions\UnauthorizedHttpExceptionFormatter::class,
    SymfonyException\HttpException::class => Formatters\HttpExceptionFormatter::class,
    Exception::class => Formatters\ExceptionFormatter::class,
],

The important thing here is that the added entry is higher than HttpException so that it has precedence. Now when we throw an UnauthorizedHttpException in our code like this throw new UnauthorizedHttpException("challenge"); we get a response like below.

HTTP/1.0 401 Unauthorized
Content-Type: application/json
WWW-Authenticate: challenge
{"status":"error","code":0,"message":"","exception":"Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException in [stack trace]","line":4,"file":"[file]"}

We can also add formatters for custom exceptions. Even though 418 I'm a teapot is a valid exception to throw when attempting to brew coffee with a teapot it currently has no implementation in the Symfony HttpKernel. So let us add it.

<?php

namespace Infrastructure\Exceptions;

use Symfony\Component\HttpKernel\Exception\HttpException;

class ImATeapotHttpException extends HttpException
{
    public function __construct(\Exception $previous = null, $code = 0)
    {
        parent::__construct(418, 'I\'m a teapot', $previous, [], $code);
    }
}
<?php

namespace Infrastructure\Exceptions;

use Exception;
use Illuminate\Http\JsonResponse;
use Optimus\Heimdal\Formatters\HttpExceptionFormatter;

class ImATeapotHttpExceptionFormatter extends HttpExceptionFormatter
{
    public function format(JsonResponse $response, Exception $e, array $reporterResponses)
    {
        parent::format($response, $e, $reporterResponses);

        $response->setData([
            'coffe_brewer' => 'http://ghk.h-cdn.co/assets/cm/15/11/320x320/55009368877e1-ghk-hamilton-beach-5-cup-coffeemaker-48136-s2.jpg',
            'teapot' => 'http://www.ikea.com/PIAimages/0282097_PE420125_S5.JPG'
        ]);

        return $response;
    }
}
'formatters' => [
    SymfonyException\UnprocessableEntityHttpException::class => Formatters\UnprocessableEntityHttpExceptionFormatter::class,
    SymfonyException\UnauthorizedHttpException::class => Infrastructure\Exceptions\UnauthorizedHttpExceptionFormatter::class,
    Infrastructure\Exceptions\ImATeapotHttpException::class => Infrastructure\Exceptions\ImATeapotHttpExceptionFormatter::class,
    SymfonyException\HttpException::class => Formatters\HttpExceptionFormatter::class,
    Exception::class => Formatters\ExceptionFormatter::class,
],

Now we throw the exception throw new ImATeapotHttpException(); we send images of coffee brewers and teapots to the consumer so they can learn the difference :-)

HTTP/1.0 401 I'm a teapot
Content-Type: application/json
{"coffe_brewer":"http:\/\/ghk.h-cdn.co\/assets\/cm\/15\/11\/320x320\/55009368877e1-ghk-hamilton-beach-5-cup-coffeemaker-48136-s2.jpg","teapot":"http:\/\/www.ikea.com\/PIAimages\/0282097_PE420125_S5.JPG"}

Send exceptions to external tracker using reporters

More often than not you want to send your exceptions to an external tracker service for better overview, handling etc. There are a lot of these but Heimdal has out of the box support for both Sentry and Bugsnag. The remainder of this article will show you how to integrate your exception handler with Sentry. For more information on reporters you can always refer to the documentation.

To add Sentry integration add the reporter to config/optimus.heimdal.php.

'reporters' => [
    'sentry' => [
        'class'  => \Optimus\Heimdal\Reporters\SentryReporter::class,
        'config' => [
            'dsn' => '[insert your DSN here]',
            // For extra options see https://docs.sentry.io/clients/php/config/
            // php version and environment are automatically added.
            'sentry_options' => []
        ]
    ]
],

That is it! Remember to fill out dsn. Now when an exception is thrown we can see it in our Sentry UI.

Pretty dope. But there is more. In Heimdal all reporters responses are added to an array which is the passed to all formatters. Sentry will return a unique ID for all exceptions logged. For instance, it may be that you want to display the specific exception ID to the user so they can hand it over to the technical support. Finding the error that an user claims have happened has never been easier. Let us see how it works.

<?php

namespace Infrastructure\Exceptions;

use Exception;
use Illuminate\Http\JsonResponse;
use Optimus\Heimdal\Formatters\ExceptionFormatter as BaseExceptionFormatter;

class ExceptionFormatter extends BaseExceptionFormatter
{
    public function format(JsonResponse $response, Exception $e, array $reporterResponses)
    {
        parent::format($response, $e, $reporterResponses);

        $response->setData(array_merge(
            (array) $response->getData(),
            ['sentry_id' => $reporterResponses['sentry']]
        ));

        return $response;
    }
}

Alright, so basically what we are trying to achieve is to create a new internal server error formatter, since we probably only want to log internal server errors to Sentry. The ID of the exception in Sentry can be found in the reporter responses array, so we just extend the base exception formatter to include this ID. Now our exceptions look like so.

HTTP/1.0 500 Internal server error
Content-Type: application/json
{"status":"error","code":0,"message":"Annoying error logged in Sentry.","exception":"Exception: Annoying error logged in Sentry. in [stack trace]","line":37,"file":"[file]","sentry_id":"e8987d63dba549a69c58b49feb2692f9"}

And we can find the exception by searching for the ID in Sentry.

If you want, it is really easy to add new reporters to Heimdal. Look at the code below to see just how simple the Sentry reporter implementation is.

<?php

namespace Optimus\Heimdal\Reporters;

use Exception;
use InvalidArgumentException;
use Raven_Client;
use Optimus\Heimdal\Reporters\ReporterInterface;

class SentryReporter implements ReporterInterface
{
    public function __construct(array $config)
    {
        if (!class_exists(Raven_Client::class)) {
            throw new InvalidArgumentException("Sentry client is not installed. Use composer require sentry/sentry.");
        }

        $this->raven = new Raven_Client($config['dsn'], $config['sentry_options']);
    }

    public function report(Exception $e)
    {
        return $this->raven->captureException($e);
    }
}

The current Sentry implementation is larger because it adds some options straight out of the box. However, the above would be a perfectly valid integration.

Conclusion

By installing Heimdal we very quickly get a good error handling system for our API. The important thing is that we provide enough information for our client so it can determine a corresponding action.

The full code to this article can be found here: larapi-series-part-3

All of these ideas and libraries are new and underdeveloped. Are you interested in helping out? Reach out on e-mail, twitter or the Larapi repository