How to define a rescue_from fallback handler?

Viewed 552

I have an exception handler module that I include in my controller to catch all kinds of Rails errors:

module ExceptionHandler
  # provides the more graceful `included` method
  extend ActiveSupport::Concern

  included do
    rescue_from ActiveRecord::RecordNotFound do |e|
      render json: { message: e.message }, status: :not_found
      logger.error(e.message)
    end

    rescue_from ActionController::ParameterMissing do |e|
      logger.error(e.message)
      render json: {
        message: "Parameter missing",
        details: e.message,
        parameter: e.param
      }, status: :bad_request
    end

    rescue_from ActiveRecord::RecordInvalid do |e|
      logger.error(e.message)
      render json: {
        attributes: e.record.errors.messages.keys,
        message: "Invalid record",
        details: e.record.errors.messages,
      }, status: :unprocessable_entity
    end

    rescue_from ActionController::RoutingError do |e|
      logger.error(e.message)
      render json: { message: e.message }, status: :not_found
    end
  end
end

However, this only catches the particular errors I've defined. When I hit some other error, I get a JSON response from Rails with pretty verbose messages, a stack trace, etc.

I want to prevent this, at least in production, and only return a 500 error with my own format.

I've read posts like this but they don't include "fallback" options for standard errors. So I tried to naively add a rescue_from StandardError, but this seems to take precedence over all other errors I've defined.

I guess I could rescue the StandardError and then case-switch from there, but that seems like a hack:

  included do
    rescue_from StandardError do |e|
      # TPDP_ better öpggomg
      logger.error(e.message)
      handle_error(e)
    end
  end

What is the proper way to deal with this?

1 Answers

The way I solved this is with an exception handler concern that I include in my application controller.

In the controller:

class MyController < ApplicationController
  include ExceptionHandler
end

And the handler exception_handler.rb:

module ExceptionHandler
  extend ActiveSupport::Concern

  ERROR_MAP = {
    ActionController::RoutingError => {
      method: :record_not_found,
      status: :not_found
    },
    ActiveRecord::RecordNotFound => {
      method: :record_not_found,
      status: :not_found
    },
    ActionController::ParameterMissing => {
      method: :parameter_missing,
      status: :unprocessable_entity
    },
    ActiveRecord::RecordInvalid => {
      method: :record_invalid,
      status: :unprocessable_entity
    }
  }.freeze

  included do
    rescue_from StandardError do |e|
      logger.error(e.message)
      handle_error(e)
    end
  end

  private

  def handle_error(e)
    # byebug
    if ERROR_MAP.keys.include? e.class
      handler = ERROR_MAP[e.class]
      body = send(handler[:method], e)
      status = handler[:status]
    else
      # fallback variant: error 500
      body = {
        message: "General server error"
      }
      status = 500
    end

    render json: body, status: status
  end

  # Define the return bodies here, per error type

  def record_not_found(e)
    {
      message: "Not found",
      details: e.message
    }
  end

  def parameter_missing(e)
    {
      message: "Parameter missing",
      details: e.message,
      parameter: e.param
    }
  end

  def record_invalid(e)
    {
      model: e.record.class.name,
      attributes: e.record.errors.messages.keys,
      message: "Invalid record",
      details: e.record.errors.messages
    }
  end
end

Obviously, you can add additional errors that should be caught, and define statuses that should be sent.

Related