Diving in Rails - Exceptions handling
Warning : This article was written a while ago, my views may have changed and this may not be relevant anymore.
Ruby code, just like any other code, can be subject to failure.
Fortunately, when an error occurs inside your Rails application, your rails server does not crash and stop serving requests.
In this article, I’ll explain how rails catches exceptions that may happen in your code, and how it renders a nice error page, depending on the Rails environment, and how you can customise it.
I won’t be talking about rescue_from
, and ActionController::Rescue
, but about the rack part.
Remember, it’s all Rack ! #
If you read my previous article about [Rails request handling](blog.siami.fr/diving-in-rails-the-request-handling), you know that Rails is based on Rack, and uses middlewares for various things.
Rails also handles exceptions with middlewares.
This article will explain how exceptions are handled in a production environment. If you are interested in understanding how Rails displays errors in development environment, have a look at the ActionDispatch::DebugExceptions
middleware.
The main middleware responsible for exception rescuing is ActionDispatch::ShowExceptions
Let’s have a look at the call
method :
def call(env)
@app.call(env)
rescue Exception => exception
if env['action_dispatch.show_exceptions'] == false
raise exception
else
render_exception(env, exception)
end
end
Pretty simple, call the Rack app, and rescue all exceptions.
If env['action_dispatch.show_exceptions']
is false, the exception is re-raised. It is configurable by setting :
config.action_dispatch.show_exceptions = false
So, what happens if the error is re-raised ? Will the server just … stop ?
No. Fortunately, Web servers will catch the exceptions themselves as well.
Some have their own error handlers, and some others may use Rack::ShowExceptions
that will just show a backtrace and some informations.
More rack ! #
Now let’s have a look at the render_exception
method :
def render_exception(env, exception)
wrapper = ExceptionWrapper.new(env, exception)
status = wrapper.status_code
env["action_dispatch.exception"] = wrapper.exception
env["PATH_INFO"] = "/#{status}"
response = @exceptions_app.call(env)
response[1]['X-Cascade'] == 'pass' ? pass_response(status) : response
rescue Exception => failsafe_error
$stderr.puts "Error during failsafe response: #{failsafe_error}\n #{failsafe_error.backtrace * "\n "}"
FAILSAFE_RESPONSE
end
def pass_response(status)
[status, {"Content-Type" => "text/html; charset=#{Response.default_charset}", "Content-Length" => "0"}, []]
end
First, we create an ExceptionWrapper
, it is used to retrieve a status code corresponding to the exception.
For example, if we try to find a non-existing ActiveRecord model, rails will raise a ActiveRecord::RecordNotFound
.
ExceptionWrapper
will look into config.action_dispatch.rescue_responses
to get an appropriate status code for this error. Since it’s a content not found, in this case it will be a 404 status code.
Then, rails adds the exception into the rack env and overwrites the PATH_INFO
variable, which represents the URL the browser is calling. It replaces the URL with the status code.
For example, in the case I described just before, the PATH_INFO
env variable would be changed to “/404”
Then, we have this line :
response = @exceptions_app.call(env)
More rack !
When an error is raised, Rails will forward the request to a rack app, and change some environment variables in order to indicate the status code and the exception.
If the rack app decides to pass the request by sending a X-Cascade
header, Rails will display en empty page.
@exceptions_app
is set when building the middleware in the default exception stack :
def build_stack
# ...
middleware.use ::ActionDispatch::ShowExceptions, show_exceptions_app
end
def show_exceptions_app
config.exceptions_app ||
ActionDispatch::PublicExceptions.new(Rails.public_path)
end
By default, the rack app used to show exceptions is ActionDispatch::PublicExceptions
, let’s have a look !
module ActionDispatch
class PublicExceptions
attr_accessor :public_path
def initialize(public_path)
@public_path = public_path
end
def call(env)
status = env["PATH_INFO"][1..-1]
request = ActionDispatch::Request.new(env)
content_type = request.formats.first
body = { :status => status, :error => Rack::Utils::HTTP_STATUS_CODES.fetch(status.to_i, Rack::Utils::HTTP_STATUS_CODES[500]) }
render(status, content_type, body)
end
private
def render(status, content_type, body)
format = "to_#{content_type.to_sym}" if content_type
if format && body.respond_to?(format)
render_format(status, content_type, body.public_send(format))
else
render_html(status)
end
end
def render_format(status, content_type, body)
[status, {'Content-Type' => "#{content_type}; charset=#{ActionDispatch::Response.default_charset}",
'Content-Length' => body.bytesize.to_s}, [body]]
end
def render_html(status)
found = false
path = "#{public_path}/#{status}.#{I18n.locale}.html" if I18n.locale
path = "#{public_path}/#{status}.html" unless path && (found = File.exist?(path))
if found || File.exist?(path)
render_format(status, 'text/html', File.read(path))
else
[404, { "X-Cascade" => "pass" }, []]
end
end
end
end
The role of this middleware is to find a file in the public
directory to render the exception, and possibly to find a file in the correct locale.
For example, when issuing a 500 error, this middleware will display the file public/500.html
The call
method will get the status from the path info, get the content type of the request thanks to ActionDispatch::Request
and prepare a hash containing the status and a human readable message in case the request is in another format than HTML in order to render it instead of the HTML file.
Customisation #
It’s possible to use a different app to render exceptions, by setting config.exceptions_app
in application.rb or in an environment config file.
Some people want to render very specific errors and need an access to rails goodness when rendering those pages.
It’s possible to go meta and assign our own rack app (the rails app) as our exception handling app, so when an exception arises, the corresponding status code will be called right into our app :
# Application.rb
config.exceptions_app = self.routes
# routes.rb
match '/500' => 'errors#default'
match '/404' => 'errors#missing'
# errors_controller.rb
def default
# Specific code
end
def missing
# Specific code
end