Providing useful error responses in a Rails API
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:
- The user isn’t logged in.
- A user’s password reset token is invalid.
- A user’s payment method was declined.
- A user tried to create a 5th foo, but the plan she bought only allows her to have 4 foos.
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:
- A relevant HTTP status code (most REST clients map status codes to exceptions/rejections to assist with frontend program flow).
-
A machine-parseable, stable identifier for the distinct error (i.e.,
foo_count_exceeded
orinvalid_invitation_token
). - 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:
-
User has a
can_create_a_foo?
method which checks if the user is allowed to make another Foo. -
current_user
is defined inApplicationController
and gives us the current user.
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:
-
Customizable error messages (you could pass arguments through to
I18n.t
) -
Multiple errors at once (allow
ErrorPayload
to accept multiple identifiers and status codes) -
Automatic mapping of exceptions to identifiers (this could be done
using
rescue_from
)
Steven Petryk
stevenpetryk ↗️