Handle errors using Rails.error

When rescuing an exception we want to:

By default, many Rails teams use a pattern:

class RequestQuote
  def call(id)
    http_client.get("/api/v1/quotes/#{id}.json")
  rescue HTTPFailure => error
    logger.error("Something went wrong")
    Sentry.capture_exception(error)
  end

  def http_client
    Faraday.new { |f| f.response :raise_error }
  end
end

This has many disadvantages:

  1. Cognitively heavy - every time we rescue, we need to decide what to do
  2. Opaque intent - no abstractions mean we’re not communicating effectively with humans
  3. Tightly coupled, duplicate code - when we change how we log, these changes will ripple out
  4. Missing structured data in logs - we can build context into the error and send this to the logs

Solution - Rails.error πŸ”—

Usage πŸ”—

Example above becomes:

class RequestQuotes
  def call(id)
    Rails.error.handle(HTTPFailure) do
      http_client.get("/api/v1/quotes/#{id}.json")
    end
  end
  # ... snip ...
end

Create subscribers:

# config/initializers/semantic_logger.rb
Rails.error.subscribe(ErrorHandler::SemanticLogger.new)

# config/initializers/sentry.rb
Rails.error.subscribe(Sentry::Rails::ErrorSubscriber.new)

And it just works.

Which method do I use? πŸ”—

Three options:

graph TD
    A[Exception in code] --> C{1. Is the exception</br>rescued in complicated</br>existing legacy code?}
    C -->|Yes| C1[2. Use #report]
    C -->|No| D{3. Handling of exception:</br>swallow it or reraise?}
    D -->|swallow/rescue| E[4. Use #handle]
    D -->|reraise| F[5. Use #record]

Examples πŸ”—

Given we have a subscriber:

class Subscriber
  def report(error, handled:, severity:, context:)
    logger.log(level: severity, error: error, handled: handled, context: context) # pseudocode
  end
end

Rails.error.subscribe(Subscriber.new)

#handle example πŸ”—

Use #handle when you need to swallow the exception.

class RequestQuotes
  def call(id)
    Rails.error.handle(HTTPFailure, fallback: -> { 'invalid' }) do
      http_client.get("/api/v1/quotes/#{id}.json")
    end
  end
end

RequestQuotes.new.call("invalid-id") # => 'invalid'
# log entry: { level: :warning, error: 'HTTPFailure', handled: true, context: {}}

#record example πŸ”—

Use #record when you need to reraise the exception.

class RequestQuotes
  def call(id)
    Rails.error.record(HTTPFailure) do             # no fallback option
      http_client.get("/api/v1/quotes/#{id}.json")
    end
  end
end

RequestQuotes.new.call("invalid-id") # => HTTPFailure (invalid-id cannot be found)
# log entry: { level: :error, error: 'HTTPFailure', handled: false, context: {}}

#report example πŸ”—

Use #report when you need to send the error along without any rescuing behavior.

Some use cases:

class RequestQuotes
  def call(id)
    http_client.get("/api/v1/quotes/#{id}.json")
  rescue HTTPFailure => error
    Rails.error.report(error, handled: true) # No block syntax
    'invalid'
  end
end

RequestQuotes.new.call("invalid-id") # => 'invalid'
# log entry: { level: :warning, error: 'HTTPFailure', handled: true, context: {}}

Bad πŸ”—

def call(relation)
  search_location = US::City.find_by_city_and_state_id!(location.city, location.state)
  # ... snip ...
rescue US::City::NotFound => exception
  logger.error("Something went wrong: #{exception}")
  Sentry.capture_exception(exception)
  relation.none
end

Good πŸ”—

def call(relation)
  Rails.error.handle(US::City::NotFound, fallback: -> { relation.none }) do
    search_location = US::City.find_by_city_and_state_id!(location.city, location.state)
    # ... snip ...
  end
end
# Once, in an initializer
Rails.error.subscribe(ErrorHandler::SemanticLogger.new)