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クラスのインスタンスが生成され、選択されたメソッドが実行される。このとき、beforeaction、afteractionといった処理をメソッドの前後に実行させることができる。メソッドの実行後に処理を行うafteractionに焦点を当てると、例えばPV数を計上しておく、レスポンスのキャッシュを生成する、といった処理にafteractionが利用される。

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

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

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

onion.png

Rack middlewareで捕捉すると良い

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

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

```ruby 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 torackresponse [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) ```