RoutingErrorやRack::QueryParser::InvalidParameterErrorが発生した場合にはRackミドルウェアベースでエラーハンドリングされます。今回はこのエラーハンドリングの処理についてコードリーディングしていきます。Railsのバージョンは5.1.5です。
ミドルウェアはこんな感じの並びで ActionDispatch::DebugExceptions, ActionDispatch::ShowExceptionsのRackミドルウェアがエラーハンドリングします。
$ bin/rake middleware
...
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions
...
run BasicRails::Application.routes
ActionDispatch::DebugExceptionsは以下のように定義されています。routingが見つからない場合、X-Cascadeヘッダにpassがセットされるため、ActionController::RoutingErrorがraiseされます。
module ActionDispatch
# This middleware is responsible for logging exceptions and
# showing a debugging page in case the request is local.
class DebugExceptions
def call(env)
request = ActionDispatch::Request.new env
_, headers, body = response = @app.call(env)
if headers["X-Cascade"] == "pass"
body.close if body.respond_to?(:close)
raise ActionController::RoutingError, "No route matches [#{env['REQUEST_METHOD']}] #{env['PATH_INFO'].inspect}"
end
response
rescue Exception => exception
raise exception unless request.show_exceptions?
render_exception(request, exception)
end
ActionDispatch#show_exceptions?はconfig.action_dispatch.show_exceptionsがfalseの場合のみfalseでそれ以外はtrueになります。デフォルトでtest環境以外はtrueがセットされるため、show_exceptions?はtrueになります。
def show_exceptions? # :nodoc:
# We're treating `nil` as "unset", and we want the default setting to be
# `true`. This logic should be extracted to `env_config` and calculated
# once.
!(get_header("action_dispatch.show_exceptions".freeze) == false)
end
action_dispatch.show_detailed_exceptionsはデフォルトでconfig.consider_all_requests_localの値が入ります。
private
def render_exception(request, exception)
backtrace_cleaner = request.get_header("action_dispatch.backtrace_cleaner")
wrapper = ExceptionWrapper.new(backtrace_cleaner, exception)
log_error(request, wrapper)
if request.get_header("action_dispatch.show_detailed_exceptions")
content_type = request.formats.first
if api_request?(content_type)
render_for_api_request(content_type, wrapper)
else
render_for_browser_request(request, wrapper)
end
else
raise exception
end
end
def render_for_browser_request(request, wrapper)
template = create_template(request, wrapper)
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)
end
# ...
def render(status, body, format)
[status, { "Content-Type" => "#{format}; charset=#{Response.default_charset}", "Content-Length" => body.bytesize.to_s }, [body]]
end
DebugExceptionsでエラーがraiseされた場合、ActionDispatch::ShowExceptionsでエラーハンドリングします。ActionDispatch::Request.show_exceptions?がtrueの場合はrender_exceptionが呼び出されます。
module ActionDispatch
class ShowExceptions
def call(env)
request = ActionDispatch::Request.new env
@app.call(env)
rescue Exception => exception
if request.show_exceptions?
render_exception(request, exception)
else
raise exception
end
end
private
def render_exception(request, exception)
backtrace_cleaner = request.get_header "action_dispatch.backtrace_cleaner"
wrapper = ExceptionWrapper.new(backtrace_cleaner, exception)
status = wrapper.status_code
request.set_header "action_dispatch.exception", wrapper.exception
request.set_header "action_dispatch.original_path", request.path_info
request.path_info = "/#{status}"
response = @exceptions_app.call(request.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
end
end
#render_exceptionではrequestのpath_infoを”/#{status}“に書き換えて@exceptions_appの#callを呼び出します。
@exceptions_appはconfig.exceptions_appが未設定の場合はActionDispatch::PublicExceptionsが設定されます。
module Rails
class Application
class DefaultMiddlewareStack
def build_stack
ActionDispatch::MiddlewareStack.new.tap do |middleware|
# ...
middleware.use ::ActionDispatch::ShowExceptions, show_exceptions_app
# ...
private
def show_exceptions_app
config.exceptions_app || ActionDispatch::PublicExceptions.new(Rails.public_path)
end
ActionDispatch::PublicExceptionsはパスからステータスコードを取得し#renderでステータスコードに応じたpublicの静的ファイルをレンダリングします。
module ActionDispatch
class PublicExceptions
def call(env)
request = ActionDispatch::Request.new(env)
status = request.path_info[1..-1].to_i
content_type = request.formats.first
body = { status: status, error: Rack::Utils::HTTP_STATUS_CODES.fetch(status, 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)
path = "#{public_path}/#{status}.#{I18n.locale}.html"
path = "#{public_path}/#{status}.html" unless (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
補足
routingで定義されていないパスを指定するとRoutingErrorが発生します。ActionDispatch::Journey::Router#serve内でルーティングが見つからなければ404のステータスコードとともにレスポンスヘッダにX-Cascade: “pass”をセットしています。module ActionDispatch
module Journey # :nodoc:
class Router # :nodoc:
def serve(req)
find_routes(req).each do |match, parameters, route|
# ...
status, headers, body = route.app.serve(req)
# ...
return [status, headers, body]
end
return [404, { "X-Cascade" => "pass" }, ["Not Found"]]
end
クエリパラメータにURIデコード不能な文字列(こういうやつ→%? )を入れるとエラーになります。Rack::QueryParser::InvalidParameterErrorが発生する場所はActionController::Instrumentation.process_actionのActionDispatch::Request#filetered_parametersを呼び出しているところです。
module ActionController
module Instrumentation
def process_action(*args)
raw_payload = {
controller: self.class.name,
action: action_name,
params: request.filtered_parameters,
headers: request.headers,
format: request.format.ref,
method: request.request_method,
path: request.fullpath
}
デコードできない文字列は最終的にRack::QueryParser#parse_nested_queryのunescapeのところでエラーになります。
module Rack
class QueryParser
def parse_nested_query(qs, d = nil)
return {} if qs.nil? || qs.empty?
params = make_params
(qs || '').split(d ? (COMMON_SEP[d] || /[#{d}] */n) : DEFAULT_SEP).each do |p|
k, v = p.split('='.freeze, 2).map! { |s| unescape(s) }
normalize_params(params, k, v, param_depth_limit)
end
return params.to_params_hash
rescue ArgumentError => e
raise InvalidParameterError, e.message
end
ちなみにQueryParserのエラーの方はBetterErrorsの前方でエラーをraiseしてくれるのでBetterErrorsで補足可能ですが、RoutingErrorはraiseではなくX-Cascade = “pass”でレスポンスヘッダを伝搬させてBetterErrorsの後方のミドルウェア(DebugExceptions)でraiseしているのでBetterErrorsで補足できません。