Rails обработка ошибок

Our Forem code base has three primary exception handling strategies.

  • Inline
  • Propagate up to Controller
  • Propagate up to Application

Each of these strategies are valid, and things propagate from up from method to controller to application.

Handling Strategies

Inline

Below is an example of a function that handles all exceptions by writing them to the log. If any part of the do_something raises an exception, we’ll capture it, and write it to the log.

Also whatever called my_function will continue processing.

def my_function
  do_something
rescue => e
  logger.error(e)
end

Another variation is to capture a specific exception.

def my_function
  do_something
rescue NoMethodError => e
  logger.error(e)
end

In the above example, the code only handles NoMethodError exceptions. If the do_something method raised a RuntimeError exception, our rescue would not handle that exception.

When specifying the exception, the rescue considers the inheritance of the exception object. The rescue will handle any exception that is a descendant of the NoMethodError class.

Propagate Up to Controller

In Ruby on Rails (Rails 📖)
, you can add handle exceptions at the controller level. Here’s the code you might see:

class UsersController
  rescue_from ActiveRecord::NotFoundError, with: :not_found

  def show
    @user = User.find(params[:id])
  end

  private

  def not_found
    render "404.html", status: 404
  end
end

See the rescue_from method documentation for more details. Of particular note is the final line in the documentation: “Exceptions raised inside exception handlers are not propagated up.”

This means if you use a rescue_from, and are looking at things in development, you won’t see the exception in the browser.

Propagate Up to Application Handling

If you don’t use inline nor rescue_from, your exceptions will bubble up to the application. And without any configuration, those visiting your site will see the default Rails exception page.

To handle exceptions at the application level you add them to the following to your application’s ./config/application.rb.

In the below example all “Pundit::NotAuthorizedError” exceptions will call the not_found method on the controller that handled the request.

config.action_dispatch.rescue_responses["Pundit::NotAuthorizedError"] = :not_found

That’s the first piece of the configuration. The second part is to add another piece to the configuration; you want to set config.consider_all_requests_local.

You’ll often see the following in the ./config/environments/production.rb.

config.consider_all_requests_local = false

This means we are configuring Rails to look at the config.action_dispatch.rescue_responses and call the corresponding methods. In other words, don’t show the ugly exceptions to our production users. There’s other configurations to ensure that we show a 500 error page, but that’s outside the scope of this post.

But in the development environment (e.g. ./config/environments/development.rb) that value will often be set to true. Which means, we are telling Rails to ignore the config.action_dispatch.rescue_responses and will render the ugly, though often useful, exception in the browser.

Conclusion

When do I use which one? That depends. The further away from where you encounter the exception the more you have to consider.

First, if you can inline rescue, that’s great. But maybe don’t inline rescue every ActiveRecord::RecordNotFound exception?

My preference is to minimize the use of rescue_from; it is the “always on”. And that means its hiding the call stack; something I find useful in my development work.

Awhile ago, I read Avdi Grimm’s Exceptional Ruby; I highly recommend picking it up and giving it a read to further understand the power and pitfalls of exceptions.

Модульный подход к обработке ошибок в Rails.

Закон Мерфи:

Как гласит закон Мерфи, все, что может пойти не так, пойдет не так, поэтому важно быть к этому готовым. Это применимо везде, даже в разработке программного обеспечения. Приложение, которое мы разрабатываем, должно быть достаточно надежным, чтобы справиться с этим. Другими словами, он должен быть устойчивым. Это именно то, о чем этот пост в блоге.

Все, что может пойти не так, пойдет не так.

— Закон Мерфи

В типичном рабочем процессе Rails мы обрабатываем ошибки на уровне контроллера. Допустим, вы пишете API с помощью Rails. Рассмотрим следующий метод контроллера для визуализации пользовательского JSON.

Когда пользовательский объект найден, он отображает его как json, в противном случае отображает ошибку json. Это типичный способ написания метода show — это Rails. Но вот в чем загвоздка. Если запись пользователя не найдена, она не попадает в блок else, а отображает резервный контент 500.html. Что ж, это было неожиданно. Это связано с тем, что если запись не найдена, возникает ошибка RecordNotFound. То же самое и с find_by! или любые методы поиска на ура.

Исключение! = Ошибка

Прежде чем мы приступим к исправлению ошибок, нам нужно понять кое-что важное. Как видно из приведенного выше примера, мы получаем ошибку ActiveRecord :: RecordNotFound. Блок try catch в ruby ​​будет выглядеть примерно так, что отлично работает.

Но когда вы хотите спастись от всех исключений, действительно важно знать разницу между исключениями и ошибками в Ruby. Никогда не выполнять восстановление из исключений. Он пытается обработать каждое исключение, наследуемое от класса Exception, и в конечном итоге останавливает выполнение.

Вместо этого нам нужно спастись от StandardError. Вот отличное сообщение в блоге, объясняющее разницу http://blog.honeybadger.io/ruby-exception-vs-standarderror-whats-the-difference/.

Спасение

Для обработки ошибок мы можем использовать блок спасения. Блок восстановления аналогичен блоку try..catch, если вы из мира Java. Вот тот же пример со спасательным блоком.

При таком подходе ошибки устраняются в методах контроллера. Хотя это работает отлично, это может быть не лучший подход для обработки ошибок. Вот несколько причин рассмотреть альтернативный подход.

  1. Толстые контроллеры: обязательно прочтите эту отличную статью Thoughtbots https://robots.oughttbot.com/skinny-controllers-skinny-models.
  2. Принцип DRY: мы просто повторяем блокировку ошибки в разных местах, что противоречит принципу DRY (не повторяйся).
  3. Ремонтопригодность: сложнее поддерживать код. Изменения ошибки, такие как формат, повлекут за собой серьезные изменения.

Альтернативный подход — переместить блок обработки ошибок в ApplicationController. Более чистый подход — написать модуль обработчика ошибок.

Обработка ошибок — модульный подход

Чтобы обрабатывать ошибки в одном месте, наш первый вариант — написать в ApplicationController. Но лучше всего отделить его от логики приложения.

Давайте создадим модуль, который обрабатывает ошибки на глобальном уровне. Создайте модуль ErrorHandler (error_handler.rb) и поместите его в папку lib / error (или в другое место для загрузки), а затем включите его в наш ApplicationController.

Важно: загрузите модуль ошибок при запуске приложения, указав его в config / application.rb.

Примечание. Я использую несколько вспомогательных классов для рендеринга вывода json. Вы можете проверить это здесь.

Прежде чем приступить к работе с модулем error_handler, вот действительно интересная статья о модулях, которую вам обязательно стоит прочитать. Если вы заметили, что метод self.included в модуле работает так же, как если бы он был помещен в исходный класс. Поэтому все, что нам нужно сделать, это включить модуль ErrorHandler в ApplicationController.

Давайте проведем рефакторинг ErrorModule для размещения нескольких блоков обработки ошибок. Так он выглядит намного чище.

Если вы заметили ошибку ActiveRecord: RecordNotFound, она также наследует StandardError. Поскольку у нас есть механизм спасения, мы получаем: record_not_found. Блок StandardError действует как резервный механизм, который обрабатывает все ошибки.

Определите собственное исключение.

Мы также можем определить наши собственные классы ошибок, которые наследуются от StandardError. Для простоты мы можем создать класс CustomError, который содержит общие переменные и методы для всех определенных пользователем классов ошибок. Теперь наша UserDefinedError расширяет CustomError.

Мы можем переопределить методы, специфичные для каждой ошибки. Например, NotVisibleError расширяет CustomError. Как вы могли заметить, мы переопределяем error_message.

Для обработки всех ошибок, определенных пользователем, все, что нам нужно сделать, это спастись от CustomError. Мы также можем спастись от конкретной ошибки, если хотим обработать ее по-другому.

404 и 500

Вы можете обрабатывать распространенные исключения, такие как 404 и 500, хотя это полностью зависит от разработчика. Нам нужно создать для него отдельный класс контроллера, ErrorsController.

Скажите Rails использовать маршруты для разрешения исключений. Нам просто нужно добавить следующую строку в application.rb.

config.exceptions_app = routes

Теперь исключения 404 возвращаются к errors # not_found, а 500 — к errors # internal_server_error.

Заключительные примечания

Модульный подход — это способ обработки ошибок в Rails. Всякий раз, когда мы хотим изменить конкретное сообщение / формат об ошибке, нам просто нужно изменить его в одном месте. При таком подходе мы также отделяем логику приложения от обработки ошибок, тем самым делая Контроллеры Slick вместо Fat.

В Rails лучше всего использовать Skinny Controllers & Models .

Вот полный Исходный код для модульного подхода к обработке ошибок. Пожалуйста, нажмите кнопку «Рекомендовать», если вы сочли это полезным. И, как всегда, не стесняйтесь отвечать, если у вас есть сомнения. Ваше здоровье!

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:
  • Cristopher Jeschke for a nice cover image

Other resources: 

  • Custom exceptions in Ruby by Appsignal
  • Error Hierarchy in Ruby by Starr Horne

Error handling for a nicer user experience is a very tough thing to pull off correctly.

Here I have provided a fully-complete template to make your life easier. This is better than a gem because its fully customizable to your application.

Note: You can view the latest version of this template at any time on my website: https://westonganger.com/posts/how-to-properly-implement-error-exception-handling-for-your-rails-controllers

Controller

class ApplicationController < ActiveRecord::Base

  def is_admin_path?
    request.path.split("/").reject{|x| x.blank?}.first == 'admin'
  end

  private
  
  def send_error_report(exception, sanitized_status_number)
    val = true

    # if sanitized_status_number == 404
    #   val = false
    # end

    # if exception.class == ActionController::InvalidAuthenticityToken
    #   val = false
    # end

    return val
  end

  def get_exception_status_number(exception)
    status_number = 500

    error_classes_404 = [
      ActiveRecord::RecordNotFound,
      ActionController::RoutingError,
    ]

    if error_classes_404.include?(exception.class)
      if current_user
        status_number = 500
      else
        status_number = 404
      end
    end

    return status_number.to_i
  end

  def perform_error_redirect(exception, error_message:)
    status_number = get_exception_status_number(exception)

    if send_error_report(exception, status_number)
      ExceptionNotifier.notify_exception(exception, data: {status: status_number})
    end

    ### Log Error
    logger.error exception

    exception.backtrace.each do |line| 
      logger.error line
    end

    if Rails.env.development?
      ### To allow for the our development debugging tools
      raise exception
    end

    ### Handle XHR Requests
    if (request.format.html? && request.xhr?)
      render template: "/errors/#{status_number}.html.erb", status: status_number
      return
    end

    if status_number == 404
      if request.format.html?
        if request.get?
          render template: "/errors/#{status_number}.html.erb", status: status_number
          return
        else
          redirect_to "/#{status_number}"
        end
      else
        head status_number
      end

      return
    end

    ### Determine URL
    if request.referrer.present?
      url = request.referrer
    else
      if current_user && is_admin_path? && request.path.gsub("/","") != admin_root_path.gsub("/","")
        url = admin_root_path
      elsif request.path != "/"
        url = "/"
      else
        if request.format.html?
          if request.get?
            render template: "/errors/500.html.erb", status: 500
          else
            redirect_to "/500"
          end
        else
          head 500
        end

        return
      end
    end

    flash_message = error_message

    ### Handle Redirect Based on Request Format
    if request.format.html?
      redirect_to url, alert: flash_message
    elsif request.format.js?
      flash[:alert] = flash_message
      flash.keep(:alert)

      render js: "window.location = '#{url}';"
    else
      head status_number
    end
  end

  rescue_from Exception do |exception|
    perform_error_redirect(exception, error_message: I18n.t('errors.system.general'))
  end

end

Testing

To test this in your specs you can use the following template:

feature 'Error Handling', type: :controller do

  ### Create anonymous controller, the anonymous controller will inherit from stated controller
  controller(ApplicationController) do
    def raise_500
      raise Errors::InvalidBehaviour.new("foobar")
    end

    def raise_possible_404
      raise ActiveRecord::RecordNotFound
    end
  end

  before(:all) do
    @user = User.first

    @error_500 = I18n.t('errors.system.general')
    @error_404 = I18n.t('errors.system.not_found')
  end

  after(:all) do
    Rails.application.reload_routes!
  end

  before :each do
    ### draw routes required for non-CRUD actions
    routes.draw do
      get '/anonymous/raise_500'
      get '/anonymous/raise_possible_404'
    end
  end

  describe "General Errors" do

    context "Request Format: 'html'" do
      scenario 'xhr request' do
        get :raise_500, format: :html, xhr: true
        expect(response).to render_template('errors/500.html.erb')
      end

      scenario 'with referrer' do
        path = "/foobar"

        request.env["HTTP_REFERER"] = path

        get :raise_500
        expect(response).to redirect_to(path)

        post :raise_500
        expect(response).to redirect_to(path)
      end

      scenario 'admin sub page' do
        sign_in @user

        request.path_info = "/admin/foobar"

        get :raise_500
        expect(response).to redirect_to(admin_root_path)

        post :raise_500
        expect(response).to redirect_to(admin_root_path)
      end

      scenario "admin root" do
        sign_in @user

        request.path_info = "/admin"

        get :raise_500
        expect(response).to redirect_to("/")

        post :raise_500
        expect(response).to redirect_to("/")
      end

      scenario 'public sub-page' do
        get :raise_500
        expect(response).to redirect_to("/")

        post :raise_500
        expect(response).to redirect_to("/")
      end

      scenario 'public root' do
        request.path_info = "/"

        get :raise_500
        expect(response).to render_template('errors/500.html.erb')
        expect(response).to have_http_status(500)

        post :raise_500
        expect(response).to redirect_to("/500")
      end

      scenario '404 error' do
        get :raise_possible_404
        expect(response).to render_template('errors/404.html.erb')
        expect(response).to have_http_status(404)

        post :raise_possible_404
        expect(response).to redirect_to('/404')

        sign_in @user

        get :raise_possible_404
        expect(response).to redirect_to('/')

        post :raise_possible_404
        expect(response).to redirect_to('/')
      end
    end

    context "Request Format: 'js'" do
      render_views ### Enable this to actually render views if you need to validate contents
      
      scenario 'xhr request' do
        get :raise_500, format: :js, xhr: true
        expect(response.body).to include("window.location = '/';")

        post :raise_500, format: :js, xhr: true
        expect(response.body).to include("window.location = '/';")
      end

      scenario 'with referrer' do
        path = "/foobar"

        request.env["HTTP_REFERER"] = path

        get :raise_500, format: :js
        expect(response.body).to include("window.location = '#{path}';")

        post :raise_500, format: :js
        expect(response.body).to include("window.location = '#{path}';")
      end

      scenario 'admin sub page' do
        sign_in @user

        request.path_info = "/admin/foobar"

        get :raise_500, format: :js
        expect(response.body).to include("window.location = '#{admin_root_path}';")

        post :raise_500, format: :js
        expect(response.body).to include("window.location = '#{admin_root_path}';")
      end

      scenario "admin root" do
        sign_in @user

        request.path_info = "/admin"

        get :raise_500, format: :js
        expect(response.body).to include("window.location = '/';")

        post :raise_500, format: :js
        expect(response.body).to include("window.location = '/';")
      end

      scenario 'public page' do
        get :raise_500, format: :js
        expect(response.body).to include("window.location = '/';")

        post :raise_500, format: :js
        expect(response.body).to include("window.location = '/';")
      end

      scenario 'public root' do
        request.path_info = "/"

        get :raise_500, format: :js
        expect(response).to have_http_status(500)

        post :raise_500, format: :js
        expect(response).to have_http_status(500)
      end

      scenario '404 error' do
        get :raise_possible_404, format: :js
        expect(response).to have_http_status(404)

        post :raise_possible_404, format: :js
        expect(response).to have_http_status(404)

        sign_in @user

        get :raise_possible_404, format: :js
        expect(response).to have_http_status(200)
        expect(response.body).to include("window.location = '/';")

        post :raise_possible_404, format: :js
        expect(response).to have_http_status(200)
        expect(response.body).to include("window.location = '/';")
      end
    end

    context "Other Request Format" do
      scenario '500 error' do
        get :raise_500, format: :json
        expect(response).to have_http_status(500)

        post :raise_500, format: :json
        expect(response).to have_http_status(500)
      end
      
      scenario '404 error' do
        get :raise_possible_404, format: :json
        expect(response).to have_http_status(404)

        post :raise_possible_404, format: :json
        expect(response).to have_http_status(404)

        sign_in @user

        get :raise_possible_404, format: :json
        expect(response).to have_http_status(500)

        post :raise_possible_404, format: :json
        expect(response).to have_http_status(500)
      end
    end

  end

end

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.

Понравилась статья? Поделить с друзьями:
  • Raidcall ошибка 22
  • Rainbow six siege код ошибки 4 0xfff0be25
  • Raid ошибка соединения не удалось подключиться к серверу
  • Rawlapi dll ошибка
  • Raid контроллер ошибка 28