Buy

Handling 404's + other Errors

What should the structure of a 404 response from our API look like? It's obvious: we'll want to return that same API Problem JSON response format. We want to return this whenever anything goes wrong.

Planning the Response

Start by planning out how the 404 should look with a new test method - test404Exception. Let's make a GET request to /api/programmers/fake and assert the easy part: that the status code is 404. We also know that we want the nice application/problem+json Content-Type header, so assert that too:

176 lines src/AppBundle/Tests/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 5
class ProgrammerControllerTest extends ApiTestCase
{
... lines 8 - 165
public function test404Exception()
{
$response = $this->client->get('/api/programmers/fake');
$this->assertEquals(404, $response->getStatusCode());
$this->assertEquals('application/problem+json', $response->getHeader('Content-Type'));
... lines 172 - 173
}
}

We know the JSON will at least have type and title properties. So what would be good values for those? This is a weird situation. Usually, type conveys what happened. But in this case, the 404 status code already says everything we need to. Using some type value like not_found would be fine, but totally redundant.

Look back at the Problem Details Spec. Under "Pre-Defined Problem Types", it says that if the status code is enough, you can set type to about:blank. And when you do this, it says that we should set title to whatever the standard text is for that status code. A 404 would be "Not Found".

Add this to the test: use $this->asserter()->assertResponsePropertyEquals() to assert that type is about:blank. And do this all again to assert that title is Not Found:

176 lines src/AppBundle/Tests/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 165
public function test404Exception()
{
$response = $this->client->get('/api/programmers/fake');
$this->assertEquals(404, $response->getStatusCode());
$this->assertEquals('application/problem+json', $response->getHeader('Content-Type'));
$this->asserter()->assertResponsePropertyEquals($response, 'type', 'about:blank');
$this->asserter()->assertResponsePropertyEquals($response, 'title', 'Not Found');
}
... lines 175 - 176

How 404's Work

A 404 happens whenever we call $this->createNotFoundException() in a controller. If you hold cmd or ctrl and click that method, you'll see that this is just a shortcut to throw a special NotFoundHttpException. And all of the other errors that might happen will ultimately just be different exceptions being thrown from different parts of our app.

The only thing that makes this exception special is that it extends that very-important HttpException class. That's why throwing this causes a 404 response. But otherwise, it's equally as exciting as any other exception.

Handling all Errors

In ApiExceptionSubscriber, we're only handling ApiException's so far. But if we handled all exceptions, we could turn everything into the nice format we want.

Reverse the logic on the if statement and set the $apiProblem variable inside:

45 lines src/AppBundle/EventListener/ApiExceptionSubscriber.php
... lines 1 - 12
class ApiExceptionSubscriber implements EventSubscriberInterface
{
public function onKernelException(GetResponseForExceptionEvent $event)
{
$e = $event->getException();
if ($e instanceof ApiProblemException) {
$apiProblem = $e->getApiProblem();
} else {
... lines 22 - 26
}
... lines 28 - 35
}
... lines 37 - 43
}

Add an else. In all other cases, we'll need to create the ApiProblem ourselves. The first thing we need to figure out is what status code this exception should have. Create a $statusCode variable. Here, check if $e is an instanceof HttpExceptionInterface: that special interface that lets an exception control its status code. So if it is, set the status code to $e->getStatusCode(). Otherwise, we have to assume that it's 500:

45 lines src/AppBundle/EventListener/ApiExceptionSubscriber.php
... lines 1 - 14
public function onKernelException(GetResponseForExceptionEvent $event)
{
... lines 17 - 18
if ($e instanceof ApiProblemException) {
... line 20
} else {
$statusCode = $e instanceof HttpExceptionInterface ? $e->getStatusCode() : 500;
... lines 23 - 26
}
... lines 28 - 35
}
... lines 37 - 45

Now use this to create an ApiProblem: $apiProblem = new ApiProblem() and pass it the $statusCode:

45 lines src/AppBundle/EventListener/ApiExceptionSubscriber.php
... lines 1 - 14
public function onKernelException(GetResponseForExceptionEvent $event)
{
... lines 17 - 18
if ($e instanceof ApiProblemException) {
... line 20
} else {
$statusCode = $e instanceof HttpExceptionInterface ? $e->getStatusCode() : 500;
$apiProblem = new ApiProblem(
$statusCode
);
}
... lines 28 - 35
}
... lines 37 - 45

For the type argument, we could pass about:blank - that is what we want. But then in ApiProblem, we'll need a constant for this, and that constant will need to be mapped to a title. But we actually want the title to be dynamic based on whatever the status code is: 404 is "Not Found", 403 is "Forbidden", etc. So, don't pass anything for the type argument. Let's handle all of this logic inside ApiProblem itself.

In there, start by making the $type argument optional:

77 lines src/AppBundle/Api/ApiProblem.php
... lines 1 - 8
class ApiProblem
{
... lines 11 - 26
public function __construct($statusCode, $type = null)
{
... lines 29 - 47
}
... lines 49 - 75
}

And if $type is exactly null, then set it to about:blank. Make sure the $this->type = $type assignment happens after all of this:

77 lines src/AppBundle/Api/ApiProblem.php
... lines 1 - 26
public function __construct($statusCode, $type = null)
{
... lines 29 - 30
if ($type === null) {
// no type? The default is about:blank and the title should
// be the standard status code message
$type = 'about:blank';
... lines 35 - 43
}
$this->type = $type;
... line 47
}
... lines 49 - 77

For $title, we just need a map from the status code to its official description. Go to Navigate->Class - that's cmd+o on a Mac. Look for Response and open the one inside HttpFoundation. It has a really handy public $statusTexts map that's exactly what we want:

1276 lines vendor/symfony/symfony/src/Symfony/Component/HttpFoundation/Response.php
... lines 1 - 11
namespace Symfony\Component\HttpFoundation;
... lines 13 - 20
class Response
... lines 22 - 124
public static $statusTexts = array(
... lines 126 - 150
403 => 'Forbidden',
404 => 'Not Found',
... lines 153 - 185
);
... lines 187 - 1274
}

Set the $title variable - but use some if logic in case we have some weird status code for some reason. If it is in the $statusTexts array, use it. Otherwise, well, this is kind of a weird situation. Use Unknown Status Code with a frowny face:

77 lines src/AppBundle/Api/ApiProblem.php
... lines 1 - 26
public function __construct($statusCode, $type = null)
{
... lines 29 - 30
if ($type === null) {
... lines 32 - 33
$type = 'about:blank';
$title = isset(Response::$statusTexts[$statusCode])
? Response::$statusTexts[$statusCode]
: 'Unknown status code :(';
... lines 38 - 43
}
... lines 45 - 47
}
... lines 49 - 77

If the $type is set - we're in the normal case. Move the check up there and add $title = self::$titles[$type]. After everything, assign $this->title = $title:

77 lines src/AppBundle/Api/ApiProblem.php
... lines 1 - 26
public function __construct($statusCode, $type = null)
{
... lines 29 - 30
if ($type === null) {
... lines 32 - 37
} else {
if (!isset(self::$titles[$type])) {
throw new \InvalidArgumentException('No title for type '.$type);
}
$title = self::$titles[$type];
}
$this->type = $type;
$this->title = $title;
}
... lines 49 - 77

Now the code we wrote in ApiExceptionSubscriber should work: a missing $type tells ApiProblem to use all the about:blank stuff. Time to try this: copy the test method name, then run:

./bin/phpunit -c app --filter test404Exception

Aaaand that's green. It's so nice when things work.

What we just did is huge. If a 404 exception is thrown anywhere in the system, it'll map to the nice Api Problem format we want. In fact, if any exception is thrown it ends up with that format. So if your database blows, an exception is thrown. Sure, that'll map to a 500 status code, but the JSON format will be just like every other error.

Leave a comment!

  • 2016-08-25 weaverryan

    Yo Chuck!

    Use 4xx! I get your point about it not being an "http" error (it's not like they sent the wrong URI or method or something), but 4xx simply means "the request (or information in the request) you sent was improper in some way". This includes validation errors, and I basically consider this to be a validation error. So yes, 4xx all the way - only 200 if the operation the user intended was successful.

    And your API clients will thank you, as they will be pre-wired to know that a 4xx means trouble. For example, in jQuery, a 4xx will trigger the error callback, instead of success (and so if the user has a global AJAX error handler, it'll hit that).

    Isn't it funny how hard it is to find clear answers on this stuff sometimes? Welcome to REST :)

    Cheers!

  • 2016-08-24 Chuck Norris

    Hi Ryan,

    Just wondering,
    I googled a lot but can't really find something concrete about this :

    whats is the best practice for custom errors.
    Do we stick with classic 4xx response code and then put the custom detail in body.
    Or do we send a 200 status code (since there is no real http error) and put detail in body.

    For example, here is what kind of error I want to handle :

    My app required that there is at least one "Project" resource. And every time an endpoint is accessed and there is no project resource found, I have to say to the client that its a business error and that he must create one in order to process.

    What's your opinion about this?

    Again, thanks.

  • 2016-01-07 Daniel

    Yes! that was the error! How could I missed? I wasn't even that difficult to spot, sometimes I get all the important things right but spend hours chasing a typo... I can keep going now with the other videos.

    Thanks a lot again! Cheers!

  • 2016-01-06 weaverryan

    Hey Daniel!

    Ah, I think I might see the problem. The line should read:


    $statusCode = $e instanceof HttpExceptionInterface ? $e->getStatusCode() : 500;

    The getStatusCode() method is the key: https://github.com/symfony/sym.... When you throw a 404 exception, it is this class: https://github.com/symfony/sym... - and it returns 404 for this. But the getCode() (which is just a method on all Exception classes) is 0. I think that's our problem :).

    Cheers!

  • 2016-01-06 Daniel

    Ryan, thanks a lot for your reply!

    I did that before, placed dump($e); on the onKernelRequest. Even print it to a file because linux terminal has some limitations on output size. On that file just found this:

    InvalidArgumentException {#447
    #message: "The HTTP status code "0" is not valid."
    #code: 0
    #file: "/home/daniel/dev/rest-symfony-start/app/bootstrap.php.cache"
    #line: 1460
    -trace: array:15 [
    0 => array:3 [
    "call" => "Symfony\Component\HttpFoundation\Response->setStatusCode()"
    "file" => "/home/daniel/dev/rest-symfony-start/app/bootstrap.php.cache:1341"
    "args" => array:1 [
    0 => 0
    ]
    ]

    So as we got this line: $statusCode = $e instanceof HttpExceptionInterface ? $e->getCode() : 500; and InvalidArgumentException is not an instanceof HttpExceptionInterface son statusCode is set to 500.

    Am I on the right path? If so, why $event->getException does not get a 404 code?

    PS: the profiler is empty

  • 2016-01-04 weaverryan

    Hey Daniel!

    Ok, there are two causes:

    A) There is some Exception being thrown somewhere that you're not aware of (so instead of the 404 NotFoundHttpException from createNotFoundException(), some other exception is being thrown)

    B) You've got a bug in your event listener.

    The problem is *probably* A) - but I'd put some debug code (i.e. var_dump+die) in your onKernelException() to help see what's going on. But, if (A) is the problem, here's a trick: you can look at the profiler for this request and see *what* exception happened. Try this:

    1) Run the test (i.e make the request that returns a 500)
    2) Go to http://dev.dev/rest-symfony-st...

    You'll see a list of all of the recent requests - the first or second should be the 500 error. Click on the link (will be the token, like abc123) to see the profiler for that request. Then click the Exception tab to see what exception *really* happened.

    I hope that helps! Making sure debugging in an API is important!

  • 2016-01-03 Daniel

    Hi Ryan, I am not getting the 404 error code, everything seems to be fine but it returns a 500 code. I'm stuck :(

    Failure! when making the following request:
    GET: http://dev.dev/rest-symfony-st...

    HTTP/1.1 500 Internal Server Error
    Date: Mon, 04 Jan 2016 11:38:03 GMT
    Server: Apache/2.4.12 (Ubuntu)
    Cache-Control: no-cache, no-cache
    Content-Length: 67
    Connection: close
    Content-Type: application/problem+json
    {
    "status": 500,
    "type": "about:blank",
    "title": "Internal Server Error"
    }