RailsでAPIをつくるときのエラー処理

例外を利用して実装すると便利な場合が多い

この投稿では、HTTP経由でJSONを返すようなWeb APIをRailsを利用して実装するとき、エラーレスポンスを返す場合の処理をどう実装するとやりやすいのか、というニッチな話題に触れる。APIでエラーを返したいとき、即ち400以上のステータスコードと共にレスポンスを返したいような場合、どう実装するのが良いか。もしリクエストの処理中にエラーが検出された場合、それ以降の処理を行わずに直ちに中断してエラーレスポンスを返したいという場合が多いため、例外を利用して実装すると便利な場合が多い。

例外を利用しない方が良い場合もある

1つのリクエストに複数の問題が含まれている場合、先に見つけた問題だけを報告するようなエラーレスポンスを返すのか、それとも問題を抱えながらも進めるところまで処理を進めて報告可能な情報を全て含むようなエラーレスポンスを返すのか、という選択肢が考えられる。例えば、AとBという2つの値をリクエストに含む必要があるが、その両方とも含まれていないという場合。Aはユーザ認証用の値で、Bはリソースを特定するための値かもしれない。こういう場合にどういうエラーレスポンスを返すかは仕様次第だが、AとBの検査を順に行うところをAの検査後にすぐさま例外を発生させてしまうと、Bの検査結果をレスポンス結果に含めるのが難しくなりがちである。

after_actionは例外時に実行されない

RailsではHTTPリクエストを受け付けると、まずRouting情報から適切なControllerのクラスとインスタンスメソッドが選択され、そのControllerクラスのインスタンスが生成され、選択されたメソッドが実行される。このとき、before_action、after_actionといった処理をメソッドの前後に実行させることができる。メソッドの実行後に処理を行うafter_actionに焦点を当てると、例えばPV数を計上しておく、レスポンスのキャッシュを生成する、といった処理にafter_actionが利用される。

after_actionに登録した処理は例外発生時には実行されないので、「レスポンスの中身を生成する訳ではないが、正常に処理が終わった場合のみ実行したい処理」を実現するのにafter_actionを利用すると、上手く処理を分離できて見通しが良くなる。この点を考えると、after_actionに正常系のための処理を任せるためにも、エラーレスポンスを返したい場合は例外を発生させるのが合理的だと言える。

rescue_fromでは捕捉できない例外がある

RailsのControllerでは、Controller内で発生した例外を捕捉するためにrescue_fromという機能が提供されている。しかしながら、rescue_fromを利用して捕捉できる例外はControllerの処理の中で発生した例外だけであり、その外側で発生する例外は捕捉できない。例えば、Railsに組み込まれているRack middlewareで発生した例外は捕捉できないし、またrescue_fromの中で発生した例外も捕捉できない。もし仮にデコードできない文字列がJSONとして送られてきたとしたら、これはRack middlewareで処理されるため、500エラーが返ることになってしまう。

onion.png
onion.png

Rack middlewareで捕捉すると良い

これを回避するには、例外を捕捉して適切なエラーレスポンスを返すためのRack middlewareを実装し、Rack middleware stackの外側の方に配置するのが良い。また、エラーレスポンスの生成に例外処理を利用する場合、エラーレスポンスの種類ごとに対応する例外クラスを用意するとレスポンスの内容を管理しやすい。加えて、Railsから発生した例外をエラーレスポンス用の例外クラスに変換するレイヤがあると、全ての例外を適切なエラーレスポンスに変換することができる。

以上をまとめて実装すると、下記のようなコードになる。MyApp::ExceptionHandlerという名前のRack middlewareをつくり、Railsのrack middleware stackにこれを配置している。エラーレスポンスを生成するための例外クラスとして、用途に合わせてMyApp::Exceptions::InternalServerErrorやMyApp::Exceptions::NotFoundを用意している。これらのクラスはto_rack_responseというインスタンスメソッドを実装しており、このメソッドの実行結果が最終的にエラーレスポンスになる。エラーレスポンスには、人間用のメッセージとしてmessageプロパティを、プログラム用のエラーコードとしてtypeプロパティを含んでいる。

module MyApp
  class ExceptionHandler
    def initialize(app)
      @app = app
    end

    def call(env)
      begin
        @app.call(env)
      rescue ActiveRecord::NotFound
        raise Exceptions::NotFound
      rescue
        raise Exceptions::InternalServerError
      end
    rescue Exceptions::Base => exception
      exception.to_rack_response
    end
  end

  module Exceptions
    class Base < StandardError
      def to_rack_response
        [status_code, headers, [body]]
      end

      private

      # 子クラスで適宜Overrideする
      def status_code
        500
      end

      def headers
        { "Content-Type" => "application/json" }
      end

      def body
        { message: error_message, type: type }.to_json
      end

      # MyApp::Exceptions::NotFoundに対して"NotFound"が返る
      def error_message
        type.humanize
      end

      # MyApp::Exceptions::NotFoundに対して"Not found"が返る
      def type
        self.class.to_s.split("::").last.underscore
      end
    end

    class InternalServerError < Base
    end

    class NotFound < Base
      def status_code
        404
      end
    end
  end
end

Rails.configuration.middleware.insert(0, MyApp::RackMiddlewares::ExceptionHandler)