Properly Implement Error / Exception Handling For Your Rails Controllers

Posted By Weston Ganger

Error handling for a nicer user experience is a very tough thing to pull off correctly. Heres a simple template to make your life easier.

Controller

class ApplicationController < ActionController::Base
  include ControllerExceptionsConcern
end
### app/controllers/concerns/controller_exceptions_concern.rb

module ControllerExceptionsConcern
  extend ActiveSupport::Concern

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

  private

  def send_error_report(exception, sanitized_status_number)
    val = true

    if sanitized_status_number == 404
      if !current_user && !request.referrer
        ### Handle Direct URL entry & Bots
        val = false
      end
    end

    if exception.class == ActionController::BadRequest
      if exception.message.start_with?("Invalid query parameters: expected ")
        val = false
      elsif exception.message.start_with?("Invalid path parameters: Invalid encoding")
        val = false
      end
    end

    return val
  end

  def get_exception_status_number(exception)
    status_number = 500

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

    if error_classes_404.include?(exception.class)
      if current_user
        if request.format.html? && request.get?
          status_number = 404
        else
          status_number = 500
        end

      else
        status_number = 404
      end
    end

    return status_number.to_i
  end

  def perform_error_redirect(exception, error_message:)
    request.format ||= "html"

    status_number = get_exception_status_number(exception)

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

    if Rails.env.development?
      ### To allow for the our development debugging tools
      raise exception
    elsif Rails.env.test?
      puts exception
      puts backtrace
    else
      logger.error exception
      exception.backtrace.each do |line|
        logger.error line
      end
    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
      is_admin_path = request.path.split("/").reject{|x| x.blank?}.first == "admin"

      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

end

Testing

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

Rspec.describe "Error Handling", type: :controller do
  # render_views ### Enable this to actually render views if you need to validate contents

  ### Create anonymous controller, the anonymous controller will inherit from stated controller
  controller(ApplicationController) do
    def raise_500
      raise StandardError.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 render_template("errors/404.html.erb")
        expect(response).to have_http_status(404)

        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

Related External Links:

Article Topic:Software Development - Ruby / Rails

Date:November 05, 2020