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-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...
- A general introduction to how to do error handling in an API
- Show you how to customize how different errors are formatted using Heimdal
- 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 JSON 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-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