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.

Rails api thumbnail
Ruby On Rails REST API
The complete guide

Create professional API applications that you can hook anything into! Learn how to code like professionals using Test Driven Development!

Take this course!

UPDATE: I've recently came with even greater way of handling Errors in Rails Web applications using "dry-monads"! It still uses this approah to serilize the errors for JSON:API purposes, but the actual mapping can be done in the more neat way!

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: