Web API Error Handling: How To Make Debugging Easier

Sylvia Fronczak Developer Tips, Tricks & Resources

 Whether you’re the consumer or producer of an API, you’ve no doubt seen that good error handling can make debugging easier. And you may have noticed that error handling works best when everyone speaks the same language and has the same expectations. But what makes for good error handling? And how can we implement it and ensure consistency across our application?

Today, let’s look at the problem of error handling from two angles: the what and the how. And since tonight is pizza night, I’m going to go with a pizza theme for our examples later on.

First, let’s consider the what.

What Are Good API Errors?

Easy-to-use applications take many things into account. One of those is providing helpful and descriptive errors to their consumers. And a great place to start involves looking at standards. Since much of the web uses JSON and REST, we’ll focus on using what’s outlined for the JSON API. Even more specifically, we’re going to look at typical error codes that we see in most web API’s—4xx and 5xx errors.

Good API errors differentiate client and server errors

The most basic division of errors identifies whether the problem is a result of a client or server error.

4xx-level errors are client errors. These are errors that the client can solve on their own by changing their request. And then 5xx errors are server errors, which indicate to the client that it’s probably not something they did wrong and that they should either retry or contact support.

This is a basic division. Unfortunately, sometimes applications get it wrong. Whether it’s speeding through to a deadline or not having a good error handling template to follow, mistakes do happen. So what you’ll want to do (and we’ll cover this later) is make sure that your application is set up with global exception handling and filtering to provide your consumers with appropriate codes.

Good API errors use status codes appropriately

One of the first errors most of us encounter is the infamous HTTP 404 status code. It’s a clear indicator to the consumer that whatever they’re looking for is not there. But you may have also seen the vast list of error codes that you’re not as familiar with.

How do you know which to use? And how specific do you have to get?

Tip 1: Stick with well-known codes

My recommendation would be to not attempt to use all the error codes available. There are basic ones that most developers know and can recognize. And others that are more obscure and that will lead to confusion.

So what error codes should we use? Well, I advise that you get familiar with at least some of them. The ones listed below are the ones I would expect to occasionally see when I call an API. Therefore I would want an application developer to generate them under the right conditions.

CodeDescriptionNotes
400Bad RequestThis is the generic error that tells us someone created a bad request. Perhaps required fields are missing or header values are not filled in.
401UnauthorizedIndicates that authentication has failed. This could be because of an expired, missing, or invalid token.
403ForbiddenIndicates that authorization failed. Alternatively, you can also use 404 so that the consumer doesn’t know that the resource even exists.
404Not FoundThe requested resource is not found. Companies like GitHub also use 404 if you try to access a resource that you do not have the authorization to access.
500Internal Server ErrorWhen something goes wrong on the server, the consumer can’t do anything about it. Just let them know there’s a problem and that they should try again later or contact support.

Now you may have noticed that I didn’t include codes like 405-Method Not Allowed or 503-Service Unavailable. These are common codes, but I would not expect an application developer to generate them. They’re either handled by frameworks, servers, or network components already.

Tip 2: Avoid codes you don’t understand

Many developers get a bit overzealous when they first learn about all the HTTP status codes available. They want to use the absolute most correct code for every situation. However, this results in unnecessary complexity in their application. It also causes consuming applications to modify their logic as well to account for more codes that could be returned.

For example, if a request header field is too large, then yes, sure, you could send a 431-Request Header Fields Too Large as the response. But then you’re forcing the consuming application’s developer to look up 431 to find out what it means and write logic to expect that response. Instead, send a 400-Bad Request and provide a helpful description that’s easy to read and understand.

Additionally, there are codes that are custom to particular uses. Let’s consider 498-Token Expired and 499-Invalid Token. When you’re first setting up your authentication, it may be tempting to use these. However, upon further inspection, you may notice that both of these are specifically for use with ArcGIS Server—whatever that may be. Additionally, 499 can also be used by nginx for indicating that a client has closed the connection.

So in this case, send back a 401-Unauthorized and provide a good message that gives enough information for the client to fix the problem.

Additionally, giving your consumers a 507-Insufficient Storage or 508-Loop Detected could give bad guys more information on how to bring your system down. Don’t make your consumers troubleshoot for you. Just say, Hey, sorry—Internal Server Error. Try again and if you still have problems, here’s an easy way to reach out and get help.


Stackify Loves Developers

Tip 3: Provide the right number of errors

Per the JSON API spec, “A server MAY choose to stop processing as soon as a problem is encountered, or it MAY continue processing and encounter multiple problems.”

They use the language “MAY” for a reason. It’s up to you. At this point, you should consider your customers and what makes sense for them. Does it make sense to first send a “Pizza size is required” error and then wait until the next submission to tell them that, oh, by the way, “Crust type is required,” too?

In this case, the answer is obvious. But there may be times when you’ll have to consider the options. For example, if errors only occur after a number of computationally expensive steps, it might make sense to return errors one at a time. In either case, always consider the consumer and the logic that will be implemented on their side. Do they want to know sooner or later that you don’t deliver to their address?

So what’s an example of errors that you would give one at a time? If you receive a request that results in a 401 because the token is not good, there’s no reason to continue on and also let them know that their request was also bad for other reasons. They’re not authorized, so they don’t need any additional information that could clue them in on your internal system structure.

Tip 4: Roll up to the most relevant error

Once again, back to the JSON API spec: “When a server encounters multiple problems for a single request, the most generally applicable HTTP error code SHOULD be used in the response. For instance, 400-Bad Request might be appropriate for multiple 4xx errors, or 500-Internal Server Error might be appropriate for multiple 5xx errors.”

Pretty simple: always choose the most relevant generalized error over the more accurate (but too technical) specific error.

Tip 5: Explain what went wrong

The standard goes on to tell you what MAY be included in the error message. They don’t take an opinionated stance on the result because they want it to be open for many use cases. My opinionated stance says that you should try to include the following:

  1. ID: Something that will let your consumers contact you. Then you can reference your logs and metrics to find out what went wrong.
  2. CODE: Even though the HTTP code comes back on the header, it’s good practice to add it to the body as well. This lets others see it and easily map it to a model.
  3. STATUS: This is the text of what your status code means. Don’t make your consumers look it up.
  4. TITLE: Let the consumer know what caused the client error here, but don’t share too much info about internal server errors.

Bonus points go to those of you that also add the following:

  1. LINK: This one is optional, but super helpful. If you can link to a help page or readme document that provides more info, you will be my hero forever. But not many people do this, so don’t feel too bad if you’re as lazy as the rest of us.
  2. DETAIL: Additional info, like how to actually fix the problem (in case you weren’t awesome enough to provide a link).

Tip 6: Separate general and domain errors

In addition to differentiating between client and server errors, as well as the different status codes, we can also classify errors as either general or domain-specific. This helps in deciding if we should write custom handling and messaging around it, or if it’s a well-known error condition.

General errors

General errors include things like using the wrong HTTP verb, failing authentication, or failing authorization. It’s typically good to stop processing quickly when these happen and get a response out to consumers. General errors are typically not domain-specific.

Domain errors

These are more specific to your domain. As an example, consider if we had a DeliveryUnavailableException or an OrderAlreadyExistsException. There might be additional things you’d want to do when these types of errors are encountered.

How Should We Implement Error Handling?

Okay, so now we know what errors will provide solid information for our consumers. Next, how do we actually go about it? There are a few different things we can do, like creating filters and error handlers. Let’s look at each of these and see how it’s done.

1. Create filters

You don’t want to repeat yourself. And you want to make sure that all your methods handle errors the same way. That way, there’s one less thing to worry about when you create yet another endpoint. A good option for this involves writing validation and exception filters that catch errors and treat them in a consistent manner.

Validation filters

First, let’s look at errors related to validation. In this case, instead of checking each field in our controller or service, we can add basic validation directly to our model, as shown below.

public class Pizza
{
    [Require]
    public string Size {get; set;}
    [Require]
    public string CrustType {get; set;}
    public string CrustSeasoning {get; set;}
    public List<Topping> Toppings {get; set;}
    [DataType(DataType.Date)]
    public DateTime OrderDate{get; set;}
}

And then we can validate all our models in our ValidationActionFilter.

public class ValidationActionFilter: ActionFilterAttribute
{
    public override void OnActionExecuting(HttpActionContext actionContext)
    {
        var modelState = actionContext.ModelState;
        if (!modelState.IsValid) {
            actionContext.Response = actionContext.Request
                .CreateErrorResponse(HttpStatusCode.BadRequest, modelState);
        }
    }
}

Exception filters

But what should we do with other types of exceptions? We can use a filter for those, as well.

First, let’s create our exception class.

public class PizzaParlorException: Exception
{
    public PizzaParlorException(HttpStatusCode statusCode, string errorCode, string errorDescription): base($"{errorCode}::{errorDescription}")
    {
        StatusCode = statusCode;
    }

    public PizzaParlorException(HttpStatusCode statusCode)
    {
        StatusCode = statusCode;
    }

    public HttpStatusCode StatusCode {get;}
}

And now let’s create our filter that will process our exceptions.

public class PizzaParlorExceptionFilterAttribute: ExceptionFilterAttribute
{
    public override void OnException(HttpActionExecutedContext context)
    {
        var exception = context.Exception as PizzaParlorException;
        if (exception != null) {
            context.Response = context.Request.CreateErrorResponse(
                exception.StatusCode, exception.Message);
        }
    }
}

And add it to our GlobalConfiguration.

GlobalConfiguration.Configuration.Filters.Add(new PizzaParlorExceptionFilterAttribute());

Now in our controllers, we throw exceptions by extending our PizzaParlorException like this:

public class OutOfDeliveryZoneException: PizzaParlorException
{
    public OutOfDeliveryZoneException(): base(HttpStatusCode.BadRequest)
    {
    }
}

// or perhaps the following

public class PizzaDeliveryAuthorizationException: PizzaParlorException
{
    public PizzaDeliveryAuthorizationException(): base(HttpStatusCode.NotAuthorized)
    {
    }
}

But that’s not the only way to handle exceptions. We can also use a GlobalErrorHandler.


Stackify Loves Developers

2. Create a GlobalExceptionHandler

First off, why would we want to use a GlobalErrorHandler over an ExceptionFilterAttribute? Well, there are some exceptions that our filters will not catch.

From the ASP.NET site, there are errors that will not be caught by our filters above. This includes exceptions thrown:

  1. From controller constructors
  2. From message handlers
  3. During routing
  4. During response content serialization

And since we want to be covered in these scenarios as well, let’s add our GlobalExceptionHandler.

class GlobalPizzaParlorExceptionHandler: ExceptionHandler
{
    public override void HandleCore(ExceptionHandlerContext context)
    {
        context.Result = new TextPlainErrorResult
        {
            Request = context.ExceptionContext.Request,
            Content = "Pizza Down! We have an Error! Please call the parlor to complete your order."
        };
    }

    private class TextPlainErrorResult: IHttpActionResult
    {
        public HttpRequestMessage Request {get; set;}
        public string Content {get; set;}

        public Task<HttpResponseMessage> ExecuteAsync(CancellationToken cancellationToken)
        {
            HttpResponseMessage response =
                new HttpResponseMessage(HttpStatusCode.InternalServerError);
            response.Content = new StringContent(Content);
            response.RequestMessage = Request;
            return Task.FromResult(response);
        }
    }
}

Now, what do we need to do in order for that filter to catch the exception? Add it to our GlobalConfiguration.

public static class SetupFiltersExtensions
{
    public static IAppBuilder SetupFilters(this IAppBuilder builder, HttpConfiguration config)
    {
        config.Services.Replace(typeof (IExceptionHandler), new PizzaParlorExceptionHandler());
        return builder;
    }
}

Now there’s just one more thing we’ll want to do.

3. Create a LoggerHandler

With the GlobalExceptionHanlder, there’s still a chance that an exception will not be caught here. However, we’re guaranteed to be able to log it. And we’re able to set up as many exception loggers as we need. I’m just going to use log4net’s logger in this example.

public class Log4NetExceptionLogger: ExceptionLogger
{
    private ILog log = LogManager.GetLogger(typeof(Log4NetExceptionLogger));

    public async override Task LogAsync(ExceptionLoggerContext context, System.Threading.CancellationToken cancellationToken)
    {
        log.Error("An unhandled exception occurred.", context.Exception);
        await base.LogAsync(context, cancellationToken);
    }

    public override void Log(ExceptionLoggerContext context)
    {
        log.Error("An unhandled exception occurred.", context.Exception);
        base.Log(context);
    }

    public override bool ShouldLog(ExceptionLoggerContext context)
    {
        return base.ShouldLog(context);
    }
}

And again, make sure to add it to your configuration.

public static class WebApiConfig
{
    public static IAppBuilder RegisterApiConfig(this IAppBuilder app, HttpConfiguration config)
    {
        config.MapHttpAttributeRoutes();
        config.Services.Add(typeof(IExceptionLogger), new Log4NetExceptionLogger());
        return app;
    }
}

One additional step you should take—use the StackifyAppender. This way, Retrace is set up to automatically collect all your logging messages.

<log4net>
    <root>
        <level value="DEBUG" />
        <appender-ref ref="StackifyAppender" />
    </root>
    <appender name="StackifyAppender" type="StackifyLib.log4net.StackifyAppender, StackifyLib.log4net" />
</log4net>

Consider Your Errors Handled!

And that’s it for today. We went over the basics of what errors provide to our consumers, as well as how to implement errors in our ASP.NET application. Now you’re ready to provide the right error and handle exceptions consistently throughout your application.

As your next step, take a look at your current projects and error handling. Verify that you’re providing the right web API error in the right context, and consider ways that you can make it easier for your consumers to fix problems on their own.

 

About Sylvia Fronczak

Sylvia is a software developer that has worked in various industries with various software methodologies. She’s currently focused on design practices that the whole team can own, understand, and evolve over time.
Improve Your Code with Retrace APM

Stackify’s APM tools are used by thousands of .NET, Java, and PHP developers all over the world.
Explore Retrace’s product features to learn more.