Rails API Painless Error Handling and Rendering

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 from StandardError, 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::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 custom ApiExceptionSerializer.

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.

subscribe and never miss a post!

Blog Logo

Sophie DeBenedetto

comments powered by Disqus
comments powered by Disqus