Skip to content

Instantly share code, notes, and snippets.

@brianjbayer
Last active January 29, 2023 16:43
Show Gist options
  • Save brianjbayer/87ea1156d9fdb94a91c1e3308249678e to your computer and use it in GitHub Desktop.
Save brianjbayer/87ea1156d9fdb94a91c1e3308249678e to your computer and use it in GitHub Desktop.
Add a basic single schema, custom response Error Handler to your Rails API with tests

Adding Rails API Error Handling with Tests

Iron Gate - Wendy Bayer

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.


Error Handling Basics

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.

Expected and Unexpected Errors

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.

Handle at the Controller

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

Rescue from StandardError

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 versus StandardError, see this great post Ruby's Exception vs StandardError: What's the difference?


Test Driven Development of the Error Handler

Here is a test-driven approach to developing your error handler...

  1. You start with the defined acceptance criteria and specification by examples

  2. You implement and run the failing tests for the error handler acceptance criteria

  3. You implement the error handler with passing tests

Acceptance Criteria

For this example, you will implement error handling that...

  1. Has the same example schema for all error responses...

    {
      "status": 0,
      "error": "string",
      "message": "string"
    }
  2. For the message, you can call the captured exception's to_s (to string) method

  3. 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"
    }
  4. Has an ActiveRecord::RecordNotFound exception handler with return status of 404 and custom response like this...

    {
      "status": 404,
      "error": "record_not_found",
      "message": "exception to string"
    }

Implementation

Here is an example implementation for the acceptance criteria.

Add the Expected Error Test

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 Failing Expected Error Test

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

Add the Unexpected Error Test

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 Failing Unexpected Error Test

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

Implement the Error Handler

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

Add the Error Handler to the Controller

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

It Should Work

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.


Other Approaches

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 using I18n.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"


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment