Image: Iron Gate by Wendy Bayer
Development is a dangerous game. Things you don't expect, things you do. Systems fail, the cloud is not infallible.
When stuff happens, you want your API to handle it and respond appropriately "controlling the message" and not with the wall of TMI that is the standard Rails error dump.
You need Error Handling in your API and probably in several places. You need a more global solution.
You are relying on your error handling and you need to know that it works. You need it testable with tests.
Your specific error handling should depend on your needs. Do you need the same basic response schema for all errors, customized messages or schemas?
Here is a basic test-driven approach that implements a basic Error Handler for all controllers that has a single response schema with default and customized exceptions and responses.
This post also presents and demonstrates some of the basic concepts of error handling.
Errors are situations outside of the intended application process where normal processing can no longer continue.
Sometimes in error handling you can recover from certain errors and continue processing. This is not that type of error handling.
Here processing can no longer continue and you want your API to respond in a controlled and determined manner instead of the default Rails exception handling.
Some errors are actually to be expected, for example
ActiveRecord::RecordNotFound
exception when a requested
id
does not exist.
Some errors are not normally expected like an untested code path that finally gets executed but not so well.
For your expected errors you can have more customized and helpful responses, for unexpected errors often less is better and a general response will do.
Usually you will want to handle errors in terms of response at the controller since it is the orchestrator of your API.
-
If you add your error handler to a controller, you can use RSpec Rails' Anonymous controller to more easily test
-
If all your API controllers will use the same error response, you can add your error handler to
ApplicationController
-
Using a Rails
Module
(mixin) is a common approach
Almost always, to "catch" the errors, you will want to use
rescue_from
and you'll want to catch StandardError
and
NOT Exception
.
Exception
is a base exception class that Ruby uses for everything like syntax errors and Control-C.
TL;DR: use rescue_from StandardError
👀 For more information on
Exception
versusStandardError
, see this great post Ruby's Exception vs StandardError: What's the difference?
Here is a test-driven approach to developing your error handler...
-
You start with the defined acceptance criteria and specification by examples
-
You implement and run the failing tests for the error handler acceptance criteria
-
You implement the error handler with passing tests
For this example, you will implement error handling that...
-
Has the same example schema for all error responses...
{ "status": 0, "error": "string", "message": "string" }
-
For the
message
, you can call the captured exception'sto_s
(to string) method -
Has a default (i.e. "catch-all") exception handler with return status of
500
and response like this...{ "status": 500, "error": "standard_error", "message": "exception to string" }
-
Has an
ActiveRecord::RecordNotFound
exception handler with return status of404
and custom response like this...{ "status": 404, "error": "record_not_found", "message": "exception to string" }
Here is an example implementation for the acceptance criteria.
Since this error is expected, it is probably able to be easily reproduced.
As this is more "normal" error behavior, you should put it where your normal response tests are or at least in your request specs.
Here is an example in spec/requests/get_thing_spec.rb
that tests
the ActiveRecord::RecordNotFound
response.
require 'rails_helper'
RSpec.describe 'get /things/{id}', type: :request do
...
# Happy Path Tests Here
...
context 'when {id} does not exists' do
let(:not_a_thing) { build(:thing).id = 0 }
before do
get thing_path(not_a_thing)
end
it 'returns "status": 404' do
expect(json_body['status']).to be(404)
end
it 'returns "error": "record_not_found"' do
expect(json_body['error']).to eql('record_not_found')
end
it 'returns "message": ...' do
expect(json_body['message']).to include("Couldn't find ")
end
end
end
def json_body
JSON.parse(response.body)
end
This test uses an id
that does not exist to generate the
ActiveRecord::RecordNotFound
exception and then verifies
the response. It uses a helper method json_body
to
parse the response body in the tests.
Run the tests to check the syntax and basic logic of the failing tests that you just wrote. It should fail something like this...
...
when {id} does not exists
returns "status": 404 (FAILED - 1)
returns "error": "record_not_found" (FAILED - 2)
returns "message": ... (FAILED - 3)
...
Failures:
1) .../{id} when {id} does not exists returns "status": 404
Failure/Error: @thing = Thing.find(params[:id])
ActiveRecord::RecordNotFound:
Couldn't find Thing with 'id'=0
Here it successfully fails and proves that...
- You are creating the desired exception in the test
- You do not yet have any error handling and how it responds without it
Since this error is not normally expected it is probably not able to be easily reproduced.
This is where you can use RSpec's
Anonymous controller
.
- The
Anonymous controller
is designed for testing controller error handling so that the desired exceptions can be easily raised
Although Rails now discourages controller
specs, this
is one situation where there may be no other way to test
(i.e. reproduce) the unexpected error.
For this example, since you can use the same error handler
for all controllers, you will add it to the
ApplicationController
.
So you can add your unexpected error tests to
spec/controllers/application_controller_spec.rb
require 'rails_helper'
RSpec.describe ApplicationController do
controller do
def index
raise StandardError, 'ruh roh'
end
end
context 'when StandardError Exception' do
before do
get :index
end
it 'returns 500' do
expect(response).to have_http_status(:internal_server_error)
end
it 'returns json' do
expect(response.content_type).to eql('application/json; charset=utf-8')
end
it 'returns "status": 500' do
expect(json_body['status']).to be(500)
end
it 'returns "error": "standard_error"' do
expect(json_body['error']).to eql('standard_error')
end
it 'returns "message": ...' do
expect(json_body['message']).to include('ruh roh')
end
end
end
def json_body
JSON.parse(response.body)
end
Here the Anonymous controller
(e.g. controller
)
inherits from the described class
(RSpec.describe ApplicationController do
). The index
route is added which generates the desired unexpected
error (here the default StandardError
with message).
controller do
def index
raise StandardError, 'ruh roh'
end
end
Run the tests to check the syntax and basic logic of the failing tests that you just wrote. It should fail something like this...
...
ApplicationController
when StandardError Exception
returns 500 (FAILED - 1)
returns json (FAILED - 2)
returns "status": 500 (FAILED - 3)
returns "error": "standard_error" (FAILED - 4)
returns "message": ... (FAILED - 5)
...
Failures:
1) ApplicationController when StandardError Exception returns 500
Failure/Error: raise StandardError, 'ruh roh'
StandardError:
ruh roh
Here it successfully fails and proves that...
- You are creating the desired exception in the test
- You do not yet have any error handling and how it responds without it
✨ Ideally at this point, you would refactor the duplicated
json_body
method to an RSpec support helper module
Using a Ruby Module
(mixin) is a good and common approach.
👀 For more information on the Ruby
Module
, see the Official Ruby FAQ on What is the difference between a class and a module?
Since this error handler is global to the API and
relates directly to this app, you can add it to
app/lib
. Another reason is that Rails automatically
loads any files under app/
.
Create your error handler in module Error::ErrorHandler
in app/lib/error/error_handler.rb
module Error
# Error module to handle expected and unexpected errors globally
module ErrorHandler
def self.included(including_class)
# Handlers must be ordered in lowest to highest priority
including_class.class_eval do
# Default Catch-all exception
rescue_from StandardError do |e|
respond(:standard_error, 500, e.to_s)
end
# Expected exceptions
rescue_from ActiveRecord::RecordNotFound do |e|
respond(:record_not_found, 404, e.to_s)
end
end
end
private
def respond(error, status, message)
json = { status:, error:, message: }.to_json
render json:, status:
end
end
end
👀 This error handler implementation is derived from the post Error Handling in Rails — The Modular Way
You can include
your implemented error handler
module in the controller. Here you are adding it
to all controllers so you include
it in
the ApplicationController
base class.
In app/controllers/application_controller.rb
class ApplicationController < ActionController::API
include Error::ErrorHandler
end
If you run the tests that you added, they should now all pass.
In a more true to TDD (Test Driven Development) approach, you would first verify that your assertions actually work and that your test will truly fail when it is not getting the desired results (i.e. test the tests).
To do this, instead of implementing the actual response
method, "stub it out" by having it return a simple
wrong response that should fail your assertions.
def respond(error, status, message)
render json: { status: 0, error: '', message: ''}, status: 999
end
end
Now if you run these tests you see something like...
1) ApplicationController when StandardError Exception returns 500
Failure/Error: expect(response).to have_http_status(:internal_server_error)
expected the response to have status code :internal_server_error (500) but it was 999
Which successfully fails proving that your assertions work and that you have a successfully failing test.
This is not the only approach or implementation for API Error Handling and this one is rather basic.
Here are a couple others, that I referenced...
-
Handling exceptions in Rails API applications is a great post on a similar approach to the one presented here but with custom child Errors of
StandardError
for specific responses -
Handling Errors in an API Application the Rails Way is a different approach using
rescue_from
blocks in the controller and serializing usingI18n.t
as a "way to have custom model and attribute names in the case where you want to return in the API something different from what’s in your database schema"