Handling exceptions in Rails API applications

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.

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:

Other resources: 



About the author

Sebastian Wilgosz

I'm a full stack developer, passionated with new technologies and personal productivity. I believe that there is no better way to learn stuff than teaching.

Aside of being programmer, I'm an author lot of technical articles, and Udemy course, a husband, father, coffee enthusiast and karate master.

I really enjoy programming, but even more I like the daily workflow automation and I love constantly optimizing everything I do.

If you like this stuff too, follow me on my social profiles and subscribe to the newsletter on the Driggl's blog.

You may be also interested in:

Comments