Dynamic Error Pages With Rails 3.2

I’ve long struggled with how best to implement dynamic error pages in Rails. The default solution, simply rendering static HTML files from the public root, is appropriately simple for 500 errors where your app may not be capable of rendering a dynamic page, but falls short for less grave errors, especially the common 404. I’ll often want to render a 404 using my application’s layout so as not to confuse users, include partials such as for a search form, and I recently worked on an internationalized app where I wanted to translate the 404 message. Rails will serve localized static pages (e.g. 404.en.html, 404.de.html), but I’d rather keep everything in my locale YAML files and render it with I18n.t('not_found').

The Old Ways

In the past, I’ve handled this two ways. The simplest was to have a catch-all route at the bottom of routes.rb like

match '*a', to: 'static_pages#error_404'

so that any request not caught by earlier routes would be directed to an error_404 action which would render the template in static_pages/error_404.html.erb. This worked fine until I needed a catch-all route for something else (dynamic, database-backed pages and redirects), and alas, like the Highlander, there can only be one. So I then changed my strategy to catching 404-like errors in application_controller.rb:

unless Rails.application.config.consider_all_requests_local
  rescue_from ActionController::RoutingError, with: :render_404
  rescue_from ActionController::UnknownAction, with: :render_404
  rescue_from ActiveRecord::RecordNotFound, with: :render_404
end

def render_404
  render template: "static_pages/error_404", layout: 'application', status: 404
end

But this always felt brittle, such as when ActionController::UnknownAction became AbstractController::ActionNotFound, or when ActionController::RoutingError was moved into to Rack middleware and ApplicationController could no longer catch it. Rails knows how to catch its own errors (to render those static pages), and I don’t like having to assume that responsibility. Thankfully, as of Rails 3.2, I no longer have to.

The New Way

Rails core member Jose Valim has a handy post on his five favorite “hidden” features in Rails 3.2. Number three is flexible exception handling which lets you set a configuration option to send exceptions through your routes.rb so you can route them wherever you want. That line is

config.exceptions_app = self.routes

Now, in my routes.rb I have

match '/404' => 'errors#error_404'
match '/422' => 'errors#error_422'
match '/500' => 'errors#error_500'

Which routes to my app/controllers/errors_controller.rb:

class ErrorsController < ApplicationController
  def error_404
    respond_to do |format|
      format.html { render status: 404 }
      format.any  { render text: "404 Not Found", status: 404 }
    end
  end

  def error_422
    respond_to do |format|
      format.html { render status: 422 }
      format.any  { render text: "422 Unprocessable Entity", status: 422 }
    end
  end

  def error_500
    render file: "#{Rails.root}/public/500.html", layout: false, status: 500
  end
end

I still render a layout-less static file for 500 errors because if things are going that wrong, my app may not be prepared to generate a layout or template. But for lesser errors I can render a full dynamic page for HTML requests and a simple text output for all others. (Without the format detection, I was seeing MissingTemplate exceptions for non-HTML, e.g. JSON or JPG, requests to unknown routes.) Plus, it’s flexible enough to make it relatively easy to handle additional error codes–one day I’ll get around to handling status code 418, “I’m a teapot”.