Our Forem code base has three primary exception handling strategies.
- Inline
- Propagate up to Controller
- Propagate up to Application
Each of these strategies are valid, and things propagate from up from method to controller to application.
Handling Strategies
Inline
Below is an example of a function that handles all exceptions by writing them to the log. If any part of the do_something
raises an exception, we’ll capture it, and write it to the log.
Also whatever called my_function
will continue processing.
def my_function
do_something
rescue => e
logger.error(e)
end
Another variation is to capture a specific exception.
def my_function
do_something
rescue NoMethodError => e
logger.error(e)
end
In the above example, the code only handles NoMethodError
exceptions. If the do_something
method raised a RuntimeError
exception, our rescue would not handle that exception.
When specifying the exception, the rescue considers the inheritance of the exception object. The rescue
will handle any exception that is a descendant of the NoMethodError
class.
Propagate Up to Controller
In Ruby on Rails (Rails 📖)
, you can add handle exceptions at the controller level. Here’s the code you might see:
class UsersController
rescue_from ActiveRecord::NotFoundError, with: :not_found
def show
@user = User.find(params[:id])
end
private
def not_found
render "404.html", status: 404
end
end
See the rescue_from
method documentation for more details. Of particular note is the final line in the documentation: “Exceptions raised inside exception handlers are not propagated up.”
This means if you use a rescue_from
, and are looking at things in development, you won’t see the exception in the browser.
Propagate Up to Application Handling
If you don’t use inline nor rescue_from
, your exceptions will bubble up to the application. And without any configuration, those visiting your site will see the default Rails exception page.
To handle exceptions at the application level you add them to the following to your application’s ./config/application.rb
.
In the below example all “Pundit::NotAuthorizedError” exceptions will call the not_found
method on the controller that handled the request.
config.action_dispatch.rescue_responses["Pundit::NotAuthorizedError"] = :not_found
That’s the first piece of the configuration. The second part is to add another piece to the configuration; you want to set config.consider_all_requests_local
.
You’ll often see the following in the ./config/environments/production.rb
.
config.consider_all_requests_local = false
This means we are configuring Rails to look at the config.action_dispatch.rescue_responses
and call the corresponding methods. In other words, don’t show the ugly exceptions to our production users. There’s other configurations to ensure that we show a 500 error page, but that’s outside the scope of this post.
But in the development environment (e.g. ./config/environments/development.rb
) that value will often be set to true. Which means, we are telling Rails to ignore the config.action_dispatch.rescue_responses
and will render the ugly, though often useful, exception in the browser.
Conclusion
When do I use which one? That depends. The further away from where you encounter the exception the more you have to consider.
First, if you can inline rescue, that’s great. But maybe don’t inline rescue every ActiveRecord::RecordNotFound
exception?
My preference is to minimize the use of rescue_from
; it is the “always on”. And that means its hiding the call stack; something I find useful in my development work.
Awhile ago, I read Avdi Grimm’s Exceptional Ruby; I highly recommend picking it up and giving it a read to further understand the power and pitfalls of exceptions.
Модульный подход к обработке ошибок в Rails.
Закон Мерфи:
Как гласит закон Мерфи, все, что может пойти не так, пойдет не так, поэтому важно быть к этому готовым. Это применимо везде, даже в разработке программного обеспечения. Приложение, которое мы разрабатываем, должно быть достаточно надежным, чтобы справиться с этим. Другими словами, он должен быть устойчивым. Это именно то, о чем этот пост в блоге.
Все, что может пойти не так, пойдет не так.
— Закон Мерфи
В типичном рабочем процессе Rails мы обрабатываем ошибки на уровне контроллера. Допустим, вы пишете API с помощью Rails. Рассмотрим следующий метод контроллера для визуализации пользовательского JSON.
Когда пользовательский объект найден, он отображает его как json, в противном случае отображает ошибку json. Это типичный способ написания метода show — это Rails. Но вот в чем загвоздка. Если запись пользователя не найдена, она не попадает в блок else, а отображает резервный контент 500.html. Что ж, это было неожиданно. Это связано с тем, что если запись не найдена, возникает ошибка RecordNotFound. То же самое и с find_by! или любые методы поиска на ура.
Исключение! = Ошибка
Прежде чем мы приступим к исправлению ошибок, нам нужно понять кое-что важное. Как видно из приведенного выше примера, мы получаем ошибку ActiveRecord :: RecordNotFound. Блок try catch в ruby будет выглядеть примерно так, что отлично работает.
Но когда вы хотите спастись от всех исключений, действительно важно знать разницу между исключениями и ошибками в Ruby. Никогда не выполнять восстановление из исключений. Он пытается обработать каждое исключение, наследуемое от класса Exception, и в конечном итоге останавливает выполнение.
Вместо этого нам нужно спастись от StandardError. Вот отличное сообщение в блоге, объясняющее разницу http://blog.honeybadger.io/ruby-exception-vs-standarderror-whats-the-difference/.
Спасение
Для обработки ошибок мы можем использовать блок спасения. Блок восстановления аналогичен блоку try..catch, если вы из мира Java. Вот тот же пример со спасательным блоком.
При таком подходе ошибки устраняются в методах контроллера. Хотя это работает отлично, это может быть не лучший подход для обработки ошибок. Вот несколько причин рассмотреть альтернативный подход.
- Толстые контроллеры: обязательно прочтите эту отличную статью Thoughtbots https://robots.oughttbot.com/skinny-controllers-skinny-models.
- Принцип DRY: мы просто повторяем блокировку ошибки в разных местах, что противоречит принципу DRY (не повторяйся).
- Ремонтопригодность: сложнее поддерживать код. Изменения ошибки, такие как формат, повлекут за собой серьезные изменения.
Альтернативный подход — переместить блок обработки ошибок в ApplicationController. Более чистый подход — написать модуль обработчика ошибок.
Обработка ошибок — модульный подход
Чтобы обрабатывать ошибки в одном месте, наш первый вариант — написать в ApplicationController. Но лучше всего отделить его от логики приложения.
Давайте создадим модуль, который обрабатывает ошибки на глобальном уровне. Создайте модуль ErrorHandler (error_handler.rb) и поместите его в папку lib / error (или в другое место для загрузки), а затем включите его в наш ApplicationController.
Важно: загрузите модуль ошибок при запуске приложения, указав его в config / application.rb.
Примечание. Я использую несколько вспомогательных классов для рендеринга вывода json. Вы можете проверить это здесь.
Прежде чем приступить к работе с модулем error_handler, вот действительно интересная статья о модулях, которую вам обязательно стоит прочитать. Если вы заметили, что метод self.included в модуле работает так же, как если бы он был помещен в исходный класс. Поэтому все, что нам нужно сделать, это включить модуль ErrorHandler в ApplicationController.
Давайте проведем рефакторинг ErrorModule для размещения нескольких блоков обработки ошибок. Так он выглядит намного чище.
Если вы заметили ошибку ActiveRecord: RecordNotFound, она также наследует StandardError. Поскольку у нас есть механизм спасения, мы получаем: record_not_found. Блок StandardError действует как резервный механизм, который обрабатывает все ошибки.
Определите собственное исключение.
Мы также можем определить наши собственные классы ошибок, которые наследуются от StandardError. Для простоты мы можем создать класс CustomError, который содержит общие переменные и методы для всех определенных пользователем классов ошибок. Теперь наша UserDefinedError расширяет CustomError.
Мы можем переопределить методы, специфичные для каждой ошибки. Например, NotVisibleError расширяет CustomError. Как вы могли заметить, мы переопределяем error_message.
Для обработки всех ошибок, определенных пользователем, все, что нам нужно сделать, это спастись от CustomError. Мы также можем спастись от конкретной ошибки, если хотим обработать ее по-другому.
404 и 500
Вы можете обрабатывать распространенные исключения, такие как 404 и 500, хотя это полностью зависит от разработчика. Нам нужно создать для него отдельный класс контроллера, ErrorsController.
Скажите Rails использовать маршруты для разрешения исключений. Нам просто нужно добавить следующую строку в application.rb.
config.exceptions_app = routes
Теперь исключения 404 возвращаются к errors # not_found, а 500 — к errors # internal_server_error.
Заключительные примечания
Модульный подход — это способ обработки ошибок в Rails. Всякий раз, когда мы хотим изменить конкретное сообщение / формат об ошибке, нам просто нужно изменить его в одном месте. При таком подходе мы также отделяем логику приложения от обработки ошибок, тем самым делая Контроллеры Slick вместо Fat.
В Rails лучше всего использовать Skinny Controllers & Models .
Вот полный Исходный код для модульного подхода к обработке ошибок. Пожалуйста, нажмите кнопку «Рекомендовать», если вы сочли это полезным. И, как всегда, не стесняйтесь отвечать, если у вас есть сомнения. Ваше здоровье!
Handling exceptions in your API applications is quite an important thing, and if you want to keep things DRY, you should think how to do it in the proper way. In our Ruby on Rails API course, I’ve shown how to implement the error handling using ErrorSerializer and ActiveModelSerializers gem and here I’m going to show you even better approach to this topic when you can unify EVERY error across the whole API application.
Ruby On Rails REST API
The complete guide
Create professional API applications that you can hook anything into! Learn how to code like professionals using Test Driven Development!
Take this course!
UPDATE: I’ve recently came with even greater way of handling Errors in Rails Web applications using «dry-monads»! It still uses this approah to serilize the errors for JSON:API purposes, but the actual mapping can be done in the more neat way!
The final approach
There is no point to cover the whole thought process of how we came with the final result, but if you’re interested in any particular part just say it in the comments. The basic assumptions were to keep things DRY and unified across the whole application.
So here is the code.
The standard error.
# app/lib/errors/standard_error.rb
module Errors
class StandardError < ::StandardError
def initialize(title: nil, detail: nil, status: nil, source: {})
@title = title || "Something went wrong"
@detail = detail || "We encountered unexpected error, but our developers had been already notified about it"
@status = status || 500
@source = source.deep_stringify_keys
end
def to_h
{
status: status,
title: title,
detail: detail,
source: source
}
end
def serializable_hash
to_h
end
def to_s
to_h.to_s
end
attr_reader :title, :detail, :status, :source
end
end
First of all we needed to have the Base error, which will be a fallback for any exception risen by our application. As we use JSON API in every server’s response, we wanted to always return an error in the format that JSON API describes.
We extracted all error-specific parts for every HTML status code we wanted to support, having a fallback to 500.
More detailed errors
As you can see this basic error was just a scaffold we could use to override particular attributes of the error object. Having that implemented, we were able to instantiate several case-specific errors to deliver more descriptive messages to our clients.
# app/lib/errors/unauthorized.rb
module Errors
class Unauthorized < Errors::StandardError
def initialize
super(
title: "Unauthorized",
status: 401,
detail: message || "You need to login to authorize this request.",
source: { pointer: "/request/headers/authorization" }
)
end
end
end
# app/lib/errors/not_found.rb
module Errors
class NotFound < Errors::StandardError
def initialize
super(
title: "Record not Found",
status: 404,
detail: "We could not find the object you were looking for.",
source: { pointer: "/request/url/:id" }
)
end
end
end
All errors are very clean and small, without any unnecessary logic involved. That’s reasonable as we don’t want to give them an opportunity to fail in an unexpected way, right?
Anyway defining the error objects is only the half of the job.
Serializing the error in Ruby Application
This approach above allowed us to use something like:
...
def show
Article.find(params[:id])
rescue ActiveRecord::RecordNotFound
e = Errors::NotFound.new
render json: ErrorSerializer.new(e), status: e.status
end
...
To serialize the standard responses we use fast_jsonapi gem from Netflix. It’s quite nice for the usual approach, but for error handling not so much so we decided to write our own ErrorSerializer.
# app/serializers/error_serializer.rb
class ErrorSerializer
def initialize(error)
@error = error
end
def to_h
serializable_hash
end
def to_json(payload)
to_h.to_json
end
private
def serializable_hash
{
errors: Array.wrap(error.serializable_hash).flatten
}
end
attr_reader :error
end
The logic is simple. It accepts an object with status, title, detail and source methods, and creates the serialized responses in the format of:
# json response
{
"errors": [
{
"status": 401,
"title": "Unauthorized",
"detail": "You need to login to authorize this request.",
"source": {
"pointer": "/request/headers/authorization"
}
}
]
}
The only problem here is that handling all of those errors in every action of the system will end up with a lot of code duplications which is not very DRY, is it? I could just raise proper errors in the services, but standard errors, like ActiveRecord::RecordNotFound would be tricky. This is then what we ended up within our API ApplicationController:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
...
include Api::ErrorHandler
...
end
We just included the ErrorHandler module, where we implemented all mappings and the logic responsible for all error handling.
module Api::ErrorHandler
extend ActiveSupport::Concern
ERRORS = {
'ActiveRecord::RecordNotFound' => 'Errors::NotFound',
'Driggl::Authenticator::AuthorizationError' => 'Errors::Unauthorized',
'Pundit::NotAuthorizedError' => 'Errors::Forbidden'
}
included do
rescue_from(StandardError, with: lambda { |e| handle_error(e) })
end
private
def handle_error(e)
mapped = map_error(e)
# notify about unexpected_error unless mapped
mapped ||= Errors::StandardError.new
render_error(mapped)
end
def map_error(e)
error_klass = e.class.name
return e if ERRORS.values.include?(error_klass)
ERRORS[error_klass]&.constantize&.new
end
def render_error(error)
render json: Api::V1::ErrorSerializer.new([error]), status: error.status
end
end
At the top, we added a nice mapper for all errors we expect to happen somewhere. Then we rescue from the default error for the rescue block, which is the StandardError, and call the handle_error method with the risen object.
Inside of this method we just do the mapping of the risen error to what we have server responses prepared to. If none of them matches, we fall back to our Errors::StandardError object so client always gets the nice error message in the server response.
We can also add extra notifiers for any error that is not mapped in the handler module, so application admins will be able to track the unexpected results.
Rising errors in the application
In Driggl we managed to create a unified solution for the whole error handling across our API application. This way we can raise our errors in a clean way without repeating any rescue blocks, and our ApplicationController will always handle that properly.
def show
Article.find!(params[:id])
end
or
def authorize!
raise Errors::Unauthorized unless currentuser
end
Handling validation errors
Well, that is a nice solution, but there is one thing we intentionally omitted so far and it is: validation failure.
The problem with validations is that we can’t write the error object for invalid request just as we did for the rest, because:
- the failure message differs based on the object type and based on attributes that are invalid
- one JSON response can have multiple errors in the returned array.
This requires us add one more error, named Invalid, which is an extended version of what we had before.
# app/lib/errors/invalid.rb
module Errors
class Invalid < Errors::StandardError
def initialize(errors: {})
@errors = errors
@status = 422
@title = "Unprocessable Entity"
end
def serializable_hash
errors.reduce([]) do |r, (att, msg)|
r << {
status: status,
title: title,
detail: msg,
source: { pointer: "/data/attributes/#{att}" }
}
end
end
private
attr_reader :errors
end
end
You can see that the main difference here is the serialized_hash and initialize method. The initialize method allows us to pass error messages hash into our error object, so then we can properly serialize the error for every single attribute and corresponding message.
Our ErrorSerializer should handle that out of the box, returning:
# json response
{
"errors": [
{
"status": 422,
"title": "Unprocessable entity",
"detail": "Can't be blank",
"source": {
"pointer": "/data/attributes/title"
}
},
{
"status": 422,
"title": "Unprocessable entity",
"detail": "Can't be blank",
"source": {
"pointer": "/data/attributes/content"
}
}
]
}
The last thing, however, is to rise it somewhere, so the handler will get the exact error data to proceed.
In the architecture we have, it’s a not big deal. It would be annoying if we would go with updating and creating objects like this:
app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def create
article = Article.new(article_params)
article.save!
end
private
def article_attributes
params.permit(:title)
end
end
As this would force us to rescue the ActiveRecord::RecordInvalid error in every action, and instantiate our custom error object there like this:
def create
article = Article.new(article_params)
article.save!
rescue ActiveRecord::RecordInvalid
raise Errors::Invalid.new(article.errors.to_h)
end
Which again would end up with repeating a lot of rescue blocks across the application.
In Driggl however, we do take advantage of Trailblazer architecture, with contracts and operations, which allows us to easily unify every controller action in the system.
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def create
process_operation!(Admin::Article::Operation::Create)
end
def update
process_operation!(Admin::Article::Operation::Update)
end
end
I won’t go into details of Trailbalzer in this article, but the point is that we could handle the validation errors once inside of the process_operation! method definition and everything works like a charm across the whole app, keeping things still nice and DRY
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
private
def process_operation!(klass)
result = klass.(serialized_params)
return render_success if result.success?
raise Errors::Invalid.new(result['contract.default'].errors.to_h)
end
def serialized_params
data = params[:data].merge(id: params[:id])
data.reverse_merge(id: data[:id])
end
def render_success
render json: serializer.new(result['model']), status: success_http_status
end
def success_http_status
return 201 if params[:action] == 'create'
return 204 if params[:action] == 'destroy'
return 200
end
end
Summary
You could think it’s a lot of code, but really, for big applications it’s just nothing comparing to repeating it in hundred of controllers and other files. In this form we managed to unify all our errors across the whole API application and we don’t need to worry anymore about unexpected failures delivering to the client.
I hope this will be useful for you too, and if you’ll find any improvements for this approach, don’t hesitate to let me know in the comments!
Special Thanks:
- Cristopher Jeschke for a nice cover image
Other resources:
- Custom exceptions in Ruby by Appsignal
- Error Hierarchy in Ruby by Starr Horne
Error handling for a nicer user experience is a very tough thing to pull off correctly.
Here I have provided a fully-complete template to make your life easier. This is better than a gem because its fully customizable to your application.
Note: You can view the latest version of this template at any time on my website: https://westonganger.com/posts/how-to-properly-implement-error-exception-handling-for-your-rails-controllers
Controller
class ApplicationController < ActiveRecord::Base
def is_admin_path?
request.path.split("/").reject{|x| x.blank?}.first == 'admin'
end
private
def send_error_report(exception, sanitized_status_number)
val = true
# if sanitized_status_number == 404
# val = false
# end
# if exception.class == ActionController::InvalidAuthenticityToken
# val = false
# end
return val
end
def get_exception_status_number(exception)
status_number = 500
error_classes_404 = [
ActiveRecord::RecordNotFound,
ActionController::RoutingError,
]
if error_classes_404.include?(exception.class)
if current_user
status_number = 500
else
status_number = 404
end
end
return status_number.to_i
end
def perform_error_redirect(exception, error_message:)
status_number = get_exception_status_number(exception)
if send_error_report(exception, status_number)
ExceptionNotifier.notify_exception(exception, data: {status: status_number})
end
### Log Error
logger.error exception
exception.backtrace.each do |line|
logger.error line
end
if Rails.env.development?
### To allow for the our development debugging tools
raise exception
end
### Handle XHR Requests
if (request.format.html? && request.xhr?)
render template: "/errors/#{status_number}.html.erb", status: status_number
return
end
if status_number == 404
if request.format.html?
if request.get?
render template: "/errors/#{status_number}.html.erb", status: status_number
return
else
redirect_to "/#{status_number}"
end
else
head status_number
end
return
end
### Determine URL
if request.referrer.present?
url = request.referrer
else
if current_user && is_admin_path? && request.path.gsub("/","") != admin_root_path.gsub("/","")
url = admin_root_path
elsif request.path != "/"
url = "/"
else
if request.format.html?
if request.get?
render template: "/errors/500.html.erb", status: 500
else
redirect_to "/500"
end
else
head 500
end
return
end
end
flash_message = error_message
### Handle Redirect Based on Request Format
if request.format.html?
redirect_to url, alert: flash_message
elsif request.format.js?
flash[:alert] = flash_message
flash.keep(:alert)
render js: "window.location = '#{url}';"
else
head status_number
end
end
rescue_from Exception do |exception|
perform_error_redirect(exception, error_message: I18n.t('errors.system.general'))
end
end
Testing
To test this in your specs you can use the following template:
feature 'Error Handling', type: :controller do
### Create anonymous controller, the anonymous controller will inherit from stated controller
controller(ApplicationController) do
def raise_500
raise Errors::InvalidBehaviour.new("foobar")
end
def raise_possible_404
raise ActiveRecord::RecordNotFound
end
end
before(:all) do
@user = User.first
@error_500 = I18n.t('errors.system.general')
@error_404 = I18n.t('errors.system.not_found')
end
after(:all) do
Rails.application.reload_routes!
end
before :each do
### draw routes required for non-CRUD actions
routes.draw do
get '/anonymous/raise_500'
get '/anonymous/raise_possible_404'
end
end
describe "General Errors" do
context "Request Format: 'html'" do
scenario 'xhr request' do
get :raise_500, format: :html, xhr: true
expect(response).to render_template('errors/500.html.erb')
end
scenario 'with referrer' do
path = "/foobar"
request.env["HTTP_REFERER"] = path
get :raise_500
expect(response).to redirect_to(path)
post :raise_500
expect(response).to redirect_to(path)
end
scenario 'admin sub page' do
sign_in @user
request.path_info = "/admin/foobar"
get :raise_500
expect(response).to redirect_to(admin_root_path)
post :raise_500
expect(response).to redirect_to(admin_root_path)
end
scenario "admin root" do
sign_in @user
request.path_info = "/admin"
get :raise_500
expect(response).to redirect_to("/")
post :raise_500
expect(response).to redirect_to("/")
end
scenario 'public sub-page' do
get :raise_500
expect(response).to redirect_to("/")
post :raise_500
expect(response).to redirect_to("/")
end
scenario 'public root' do
request.path_info = "/"
get :raise_500
expect(response).to render_template('errors/500.html.erb')
expect(response).to have_http_status(500)
post :raise_500
expect(response).to redirect_to("/500")
end
scenario '404 error' do
get :raise_possible_404
expect(response).to render_template('errors/404.html.erb')
expect(response).to have_http_status(404)
post :raise_possible_404
expect(response).to redirect_to('/404')
sign_in @user
get :raise_possible_404
expect(response).to redirect_to('/')
post :raise_possible_404
expect(response).to redirect_to('/')
end
end
context "Request Format: 'js'" do
render_views ### Enable this to actually render views if you need to validate contents
scenario 'xhr request' do
get :raise_500, format: :js, xhr: true
expect(response.body).to include("window.location = '/';")
post :raise_500, format: :js, xhr: true
expect(response.body).to include("window.location = '/';")
end
scenario 'with referrer' do
path = "/foobar"
request.env["HTTP_REFERER"] = path
get :raise_500, format: :js
expect(response.body).to include("window.location = '#{path}';")
post :raise_500, format: :js
expect(response.body).to include("window.location = '#{path}';")
end
scenario 'admin sub page' do
sign_in @user
request.path_info = "/admin/foobar"
get :raise_500, format: :js
expect(response.body).to include("window.location = '#{admin_root_path}';")
post :raise_500, format: :js
expect(response.body).to include("window.location = '#{admin_root_path}';")
end
scenario "admin root" do
sign_in @user
request.path_info = "/admin"
get :raise_500, format: :js
expect(response.body).to include("window.location = '/';")
post :raise_500, format: :js
expect(response.body).to include("window.location = '/';")
end
scenario 'public page' do
get :raise_500, format: :js
expect(response.body).to include("window.location = '/';")
post :raise_500, format: :js
expect(response.body).to include("window.location = '/';")
end
scenario 'public root' do
request.path_info = "/"
get :raise_500, format: :js
expect(response).to have_http_status(500)
post :raise_500, format: :js
expect(response).to have_http_status(500)
end
scenario '404 error' do
get :raise_possible_404, format: :js
expect(response).to have_http_status(404)
post :raise_possible_404, format: :js
expect(response).to have_http_status(404)
sign_in @user
get :raise_possible_404, format: :js
expect(response).to have_http_status(200)
expect(response.body).to include("window.location = '/';")
post :raise_possible_404, format: :js
expect(response).to have_http_status(200)
expect(response.body).to include("window.location = '/';")
end
end
context "Other Request Format" do
scenario '500 error' do
get :raise_500, format: :json
expect(response).to have_http_status(500)
post :raise_500, format: :json
expect(response).to have_http_status(500)
end
scenario '404 error' do
get :raise_possible_404, format: :json
expect(response).to have_http_status(404)
post :raise_possible_404, format: :json
expect(response).to have_http_status(404)
sign_in @user
get :raise_possible_404, format: :json
expect(response).to have_http_status(500)
post :raise_possible_404, format: :json
expect(response).to have_http_status(500)
end
end
end
end
Are you sick and tired of handling endless exceptions, writing custom logic to handle bad API requests and serializing the same errors over and over?
What if I told you there was a way to abstract away messy and repetitive error raising and response rendering in your Rails API? A way for you to write just one line of code (okay, two lines of code) to catch and serialize any error your API needs to handle? All for the low low price of just $19.99!
Okay, I’m kidding about that last part. This is not an infomercial.
Although Liz Lemon makes the snuggie (sorry, «slanket») look so good, just saying.
In this post, we’ll come to recognize the repetitive nature of API error response rendering and implement an abstract pattern to DRY up our code. How? We’ll define a set of custom errors, all subclassed under the same parent and tell the code that handles fetching data for our various endpoints to raise these errors. Then, with just a few simple lines in a parent controller, we’ll rescue any instance of this family of errors, rendering a serialized version of the raised exception, thus taking any error handling logic out of our individual endpoints.
I’m so excited. Let’s get started!
Recognizing the Repetition in API Error Response Rendering
The API
For this post, we’ll imagine that we’re working on a Rails API that serves data to a client e-commerce application. Authenticated users can make requests to view their past purchases and to make a purchase, among other things.
We’ll say that we have the following endpoints:
POST '/purchases'
GET '/purchases'
Any robust API will of course come with specs.
API Specs
Our specs look something like this:
Purchases
Request
GET api/v1/purchases
# params
{
start_date: " ",
end_date: " "
}
Success Response
# body
{
status: "success",
data: {
items: [
{
id: 1,
name: "rice cooker",
description: "really great for cooking rice",
price: 14.95,
sale_date: "2016-12-31"
},
...
]
}
}
# headers
{"Authorization" => "Bearer <token>"}
Error Response
{
status: "error",
message: " ",
code: " "
}
code | message |
3000 | Can’t find purchases without start and end date |
Yes, I’ve decided querying purchases requires a date range. I’m feeling picky.
Request
POST api/v1/purchases
# params
{
item_id: 2
}
Success Response
# body
{
status: "success",
data: {
purchase_id: 42,
item_id: 2
purchase_status: "complete"
}
}
# headers
{"Authorization" => "Bearer <token>"}
Error Response
{
status: "error",
message: " ",
code: " "
}
code | message |
4000 | item_id is required to make a purchase |
Error Code Pattern
With just a few endpoint specs, we can see that there is a lot of shared behavior. For the GET /purchases
request and POST /purchases
requests, we have two specific error scenarios. BUT, in both of the cases in which we need to respond with an error, the response format is exactly the same. It is only the content of the code
and message
keys of our response body that needs to change.
Let’s take a look at what this error handling could look like in our API controllers.
API Controllers
# app/controllers/api/v1/purchases_controller.rb module Api module V1 class PurchasesController < ApplicationController def index if params[:start_date] && params[:end_date] render json: current_user.purchases else render json: {status: "error", code: 3000, message: "Can't find purchases without start and end date"} end end def create if params[:item_id] purchase = Purchase.create(item_id: params[:item_id], user_id: current_user.id) render json: purchase else render json: {status: "error", code: 4000, message: "item_id is required to make a purchase} end end end end end
Both of our example endpoints contain error rendering logic and they are responsible for composing the error to be rendered.
This is repetitious, and will only become more so as we build additional API endpoints. Further, we’re failing to manage our error generation in a centralized away. Instead creating individual error JSON packages whenever we need them.
Let’s clean this up. We’ll start by building a set of custom error classes, all of which will inherit from the same parent.
Custom Error Classes
All of our custom error classes will be subclassed under ApiExceptions::BaseException
. This base class will contain our centralized error code map. We’ll put our custom error classes in the lib/
folder.
# lib/api_exceptions/base_exception.rb module ApiExceptions class BaseException < StandardError include ActiveModel::Serialization attr_reader :status, :code, :message ERROR_DESCRIPTION = Proc.new {|code, message| {status: "error | failure", code: code, message: message}} ERROR_CODE_MAP = { "PurchaseError::MissingDatesError" => ERROR_DESCRIPTION.call(3000, "Can't find purchases without start and end date"), "PurchaseError::ItemNotFound" => ERROR_DESCRIPTION.call(4000, "item_id is required to make a purchase") } def initialize error_type = self.class.name.scan(/ApiExceptions::(.*)/).flatten.first ApiExceptions::BaseException::ERROR_CODE_MAP .fetch(error_type, {}).each do |attr, value| instance_variable_set("@#{attr}".to_sym, value) end end end end
We’ve done a few things here.
- Inherit
BaseException
fromStandardError
, so that instances of our class can be raised and rescued. - Define an error map that will call on a proc to generate the correct error code and message.
- Created
attr_reader
s for the attributes we want to serialize - Included
ActiveModel::Serialization
so that instances of our class can be serialized by Active Model Serializer. - Defined an
#initialize
method that will be called by all of our custom error child classes. When this method runs, each child class will use the error map to set the correct values for the@status
,@code
and@message
variables.
Now we’ll go ahead and define our custom error classes, as mapped out in our error map.
# lib/api_exceptions/purchase_error.rb
module ApiExceptions
class PurchaseError < ApiExceptions::BaseException
end
end
# lib/api_exceptions/purchase_error/missing_dates_error.rb
module ApiExceptions
class PurchaseError < ApiExceptions::BaseException
class MissingDatesError < ApiExceptions::PurchaseError
end
end
end
# lib/api_exceptions/purchase_error/item_not_found.rb
module ApiExceptions
class PurchaseError < ApiExceptions::BaseException
class ItemNotFound < ApiExceptions::PurchaseError
end
end
end
Now that are custom error classes are defined, we’re ready to refactor our controller.
Refactoring The Controller
For this refactor, we’ll just focus on applying our new pattern to a single endpoint, since the same pattern can be applied again and again. We’ll take a look at the POST /purchases
request, handled by PurchasesController#create
Instead of handling our login directly in the controller action, we’ll build a service to validate the presence of item_id
. The service should raise our new custom ApiExceptions::PurchaseError::ItemNotFound
if there is no item_id
in the params.
module Api module V1 class PurchasesController < ApplicationController ... def create purchase_generator = PurchaseGenerator.new(user_id: current_user.id, item_id: params[:item_id]) render json: purchase_generator end end end end
Our service is kind of like a service-model hybrid. It exists to do a job for us––generate a purchase––but it also needs a validation and it will be serialized as the response body to our API request. For this reason, we’ll define it in app/models
# app/models class PurchaseGenerator include ActiveModel::Serialization validates_with PurchaseGeneratorValidator attr_reader :purchase, :user_id, :item_id def initialize(user_id:, item_id:) @user_id = user_id @item_id = item_id @purchase = Purchase.create(user_id: user_id, item_id: item_id) if valid? end end
Now, let’s build our custom validator to check for the presence of item_id
and raise our error if it is not there.
class PostHandlerValidator < ActiveModel::Validator def validate(record) validate_item_id end def validate_item_id raise ApiExceptions::PurchaseError::ItemNotFound.new unless record.item_id end end
This custom validator will be called with the #valid?
method runs.
So, the very simple code in our Purchases Controller will raise the appropriate error if necessary, without us having to write any control flow in the controller itself.
But, you may be wondering, how will we rescue or handle this error and render the serialized error?
Universal Error Rescuing and Response Rendering
This part is really cool. With the following line in our Application Controller, we can rescue *any error subclassed under ApiExceptions::BaseException
:
class ApplicationController < ActionController::Base rescue_from ApiExceptions::BaseException, :with => :render_error_response end
This line will rescue any such errors by calling on a method render_error_response
, which we’ll define here in moment, and passing that method the error that was raised.
all our render_error_response
method has to do and render that error as JSON.
class ApplicationController < ActionController::Base rescue_from ApiExceptions::BaseException, :with => :render_error_response ... def render_error_response(error) render json: error, serializer: ApiExceptionsSerializer, status: 200 end end
Our ApiExceptionSerializer
is super simple:
class ApiExceptionSerializer < ActiveModel::Serializer
attributes :status, :code, :message
end
And that’s it! We’ve gained super-clean controller actions that don’t implement any control flow and a centralized error creation and serialization system.
Let’s recap before you go.
Conclusion
We recognized that, in an API, we want to follow a strong set of conventions when it comes to rendering error responses. This can lead to repetitive controller code and an endlessly growing and scattered list of error message definitions.
To eliminate these very upsetting issues, we did the following:
- Built a family of custom error classes, all of which inherit from the same parent and are namespaced under
ApiExceptions
. - Moved our error-checking control flow logic out of the controller actions, and into a custom model.
- Validated that model with a custom validator that raises the appropriate custom error instance when necessary.
- Taught our Application Controller to rescue any exceptions that inherit from
ApiExceptions::BaseException
by rendering as JSON the raised error, with the help of our customApiExceptionSerializer
.
Keep in mind that the particular approach of designing a custom model with a custom validator to raise our custom error is flexible. The universally applicable part of this pattern is that we can build services to raise necessary errors and call on these services in our controller actions, thus keeping error handling and raising log out of individual controller actions entirely.