One of the nice things about working with rails is that when something goes wrong in development, you get a really nice error detail page. You get a nice backtrace, with the parts relevant to your app highlighted. You can see the params that got posted, as well as inspect environment and session variables.

Today we're going to take a look at how these fancy error pages work.

exception

Cracking open Actionpack

The file we're going to be mostly concerned with today is actionpack/lib/action_dispatch/middleware/debug_exceptions.rb. It does most of the heavy lifting when it comes to displaying those development mode error pages. If you're curious about where the production mode error screens come from, check out public_exceptions.rb.

Rack Middleware

If you're not familiar with rack middleware, the concept is simple. It allows you to intercept HTTP requests before they get to your app, and to intercept the app's output before it goes back to the user.

Here's a simple middleware that doesn't do anything interesting.

class MyMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    @app.call(env)
  end
end

Rescuing all exceptions via rack middleware

Any exception that occurs in your app occurs as a result of calling @app.call(). So rescuing all exceptions in a rack app is as simple as adding a rescue clause to the middleware.

def call(env)
  @app.call(env)
rescue StandardError => exception
  # this is a method we have to provide to generate the exception page
  render_exception(env, exception)
end

Anything returned from the call method will be treated as if it were a normal web page. So the content returned by render_exception replaces the original response.

Rendering the exception

I've excerpted the render_exception method from ActionDispatch::DebugExceptions. As you can see, it simply pulls the relevant data from the exception and feeds it into an ERB template.

def render_exception(env, exception)
  wrapper = ExceptionWrapper.new(env, exception)
  log_error(env, wrapper)

  if env['action_dispatch.show_detailed_exceptions']
    request = Request.new(env)
    template = ActionView::Base.new([RESCUES_TEMPLATE_PATH],
      request: request,
      exception: wrapper.exception,
      application_trace: wrapper.application_trace,
      framework_trace: wrapper.framework_trace,
      full_trace: wrapper.full_trace,
      routes_inspector: routes_inspector(exception),
      source_extract: wrapper.source_extract,
      line_number: wrapper.line_number,
      file: wrapper.file
    )
    file = "rescues/#{wrapper.rescue_template}"

    if request.xhr?
      body = template.render(template: file, layout: false, formats: [:text])
      format = "text/plain"
    else
      body = template.render(template: file, layout: 'rescues/layout')
      format = "text/html"
    end
    render(wrapper.status_code, body, format)
  else
    raise exception
  end
end

def render(status, body, format)
  [status, {'Content-Type' => "#{format}; charset=#{Response.default_charset}", 'Content-Length' => body.bytesize.to_s}, [body]]
end

Other uses

You can use this rack middleware trick to do lots of interesting things with exceptions. Here at Honeybadger, we use it to intercept errors and record them to our API.  Here's the code we use to do it:

def call(env)
  config.with_request(::Rack::Request.new(env)) do
    begin
      env['honeybadger.config'] = config
      response = @app.call(env)
    rescue Exception => raised
      env['honeybadger.error_id'] = notify_honeybadger(raised, env)
      raise
    end

    framework_exception = framework_exception(env)
    if framework_exception
      env['honeybadger.error_id'] = notify_honeybadger(framework_exception, env)
    end

    response
  end
ensure
  Honeybadger.context.clear!
end