Steven Petryk

I'm a software engineer living and learning in Seattle, WA.

Providing useful error responses in a Rails API

Published

You’re an experienced backend developer. You know how hard it can be to handle errors. In particular, the frontend developers working on your project want to know why certain errors occur, and they want something beyond a generic HTTP status code to tell them what’s going on behind the scenes.

To the chagrin of many developers, Rails doesn’t really have an opinion on how to handle errors beyond ActiveModel validation errors. Here are just a few examples of oddball errors that don’t involve models:

Yikes. Handling these errors is hard enough, but representing them as a response adds a whole new dimension of complexity. We’re beyond the typical if the model saved, render it; otherwise, render the errors paradigm. You know, this paradigm:

class FoosController < ApplicationController
def create
foo = Foo.new(create_params)

if foo.save
render json: foo
else
render json: foo.errors, status: :bad_request
end
end

# ...
end

Let’s implement the last example in the above list (limiting the number of things a user is allowed to create). The first thing we need to do is determine a reasonable standard for how to format these errors. A good rule of thumb:

As an API developer, your primary consideration is empowering the frontend developer. First and foremost, design your API so that they can quickly and easily diagnose errors.

I made it a giant quote so it’d seem important, but I just made it up. Still though, it’s advice that’s gotten me far. Anything to prevent the frontend developer from pestering you is worthwhile (perhaps that should be the real big quote).

With that in mind, our frontend developer needs three things to succeed and properly consume our API:

  1. A relevant HTTP status code (most REST clients map status codes to exceptions/rejections to assist with frontend program flow).
  2. A machine-parseable, stable identifier for the distinct error (i.e., foo_count_exceeded or invalid_invitation_token).
  3. A human-parseable, verbose error message.

Sounds easy enough—but we still have no opinion about how to format this. For the sake of this article, I’m going to use JSONAPI. It’s a relatively popular standard for JSON responses, but you can use whatever you want, as long as your errors are shaped consistently. JSONAPI says our “too many foos” error should look something like this:

{
"errors": [
{
"status": "403",
"code": "foo_limit_exceeded",
"title": "Foo limit exceeded for plan",
"detail": "The user has exceeded the number of Foos allowed on their plan"
}
]
}

I dig it. If you don’t dig it, that’s fine—the following code will be easy to customize. Notice that detail isn’t exactly user-friendly: its main purpose is to help the frontend developer debug.


I’m lazy, so I’d like to be able to simply call

render_error_payload(:foo_limit_exceeded, status: :forbidden)

to render the above error payload. Furthermore, I don’t think that "The user has exceeded the number of Foos allowed on their plan" belongs anywhere in our controller logic. Human-readable strings, whether they’re intended for the frontend developer or the end user, really belong in an I18n definition (regardless of whether we actually do any internationalization, which most APIs don’t do). So let’s put it in there, along with both of our additional fields.

en:
errors:
foo_limit_exceeded:
title: Foo limit exceeded for plan
detail: The user has exceeded the number of Foos allowed on their plan

This makes writing render_error_payload a breeze. First, let’s pretend we have an ErrorPayload class to offload this logic onto.

class ApplicationController < ActionController::API
# ...

protected

def render_error_payload(identifier, status: :bad_request)
render json: ErrorPayload.new(identifier, status), status: status
end
end

Nice and clean. Now, let’s build the class itself:

class ErrorPayload
attr_reader :identifier, :status

def initialize(identifier, status)
@identifier = identifier
@status = status
end

def as_json(*)
{
status: Rack::Utils.status_code(status),
code: identifier,
title: translated_payload[:title],
detail: translated_payload[:detail],
}
end

def translated_payload
I18n.translate("errors.#{identifier}")
end
end

Throw this into your app/models directory if it suits you. This should all seem pretty familiar aside from line 11. Rack::Utils.status_code is a nifty utility function provided by Rack. If we pass it a symbol, like :forbidden, it’ll return 403. If we pass it 403, it’ll also return 403. Neat! This means that we can do the following:

render_error_payload(:not_logged_in, status: :unauthorized)
# or
render_error_payload(:not_logged_in, status: 401)

And get the exact same response.


Now, let’s go back to our original example. We have a FoosController:

class FoosController < ApplicationController
def create
foo = Foo.new(create_params)

if foo.save
render json: foo
else
render json: foo.errors, status: :bad_request
end
end

# ...
end

And we want to add the ability to render our foo_limit_exceeded error. Let’s pretend, for the sake of example, the following:

class FoosController < ApplicationController
before_action :authorize_create, only: :create

def create
foo = Foo.new(create_params)

if foo.save
render json: foo
else
render json: foo.errors, status: :bad_request
end
end

private

def authorize_create
return if current_user.can_create_a_foo?

render_error_payload(:foo_limit_exceeded, status: :forbidden)
end

# ...
end

Worth noting is that we threw this check into a before_action. This way, if we render an error, it will halt the rest of the render pipeline.

And that’s all there is to it. Our frontend dev’s happy, and the user’s happy by extension. 🕶

Next steps

Some things you may consider implementing could include:

Steven Petryk

twitter find me on twitter stevenpetryk ↗️