2018-04-03

Railsのミドルウェアのエラーハンドリング

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で補足できません。

このエントリーをはてなブックマークに追加