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
BaseExceptionfromStandardError, 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_readers for the attributes we want to serialize - Included
ActiveModel::Serializationso that instances of our class can be serialized by Active Model Serializer. - Defined an
#initializemethod 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,@codeand@messagevariables.
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::BaseExceptionby 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.