🤖

Ecto.NoResultsError発生時にPhoenixはどのようにして404を返しているのか

2022/02/19に公開

Phoenixで Repo.get!/2 で存在しないidを指定した場合など Ecto.NoResultsError がraiseされた場合に、Phoenixでは500ではなく404でレスポンスが返ってきます。

phoenix debug error

devtoolやPhoenixのログを見ても、404エラー(Not Found)としてレスポンスを返していることが分かります。

[info] GET /posts/1
[debug] Processing with SampleWeb.PostController.show/2
  Parameters: %{"id" => "1"}
  Pipelines: [:browser]
[debug] QUERY OK source="posts" db=0.3ms idle=1189.8ms
SELECT p0."id", p0."body", p0."title", p0."inserted_at", p0."updated_at" FROM "posts" AS p0 WHERE (p0."id" = ?) [1]
[info] Sent 404 in 17ms
[debug] ** (Ecto.NoResultsError) expected at least one result but got none in query:

from p0 in Sample.Content.Post,
  where: p0.id == ^"1"

    (ecto 3.7.1) lib/ecto/repo/queryable.ex:156: Ecto.Repo.Queryable.one!/3
    (sample 0.1.0) lib/sample_web/controllers/post_controller.ex:30: SampleWeb.PostController.show/2
    (sample 0.1.0) lib/sample_web/controllers/post_controller.ex:1: SampleWeb.PostController.action/2
    (sample 0.1.0) lib/sample_web/controllers/post_controller.ex:1: SampleWeb.PostController.phoenix_controller_pipeline/2
    (phoenix 1.6.6) lib/phoenix/router.ex:355: Phoenix.Router.__call__/2
    (sample 0.1.0) lib/sample_web/endpoint.ex:1: SampleWeb.Endpoint.plug_builder_call/2
    (sample 0.1.0) lib/plug/debugger.ex:136: SampleWeb.Endpoint."call (overridable 3)"/2
    (sample 0.1.0) lib/sample_web/endpoint.ex:1: SampleWeb.Endpoint.call/2
    (phoenix 1.6.6) lib/phoenix/endpoint/cowboy2_handler.ex:54: Phoenix.Endpoint.Cowboy2Handler.init/4
    (cowboy 2.9.0) /private/tmp/sample/deps/cowboy/src/cowboy_handler.erl:37: :cowboy_handler.execute/2
    (cowboy 2.9.0) /private/tmp/sample/deps/cowboy/src/cowboy_stream_h.erl:306: :cowboy_stream_h.execute/3
    (cowboy 2.9.0) /private/tmp/sample/deps/cowboy/src/cowboy_stream_h.erl:295: :cowboy_stream_h.request_process/3
    (stdlib 3.15) proc_lib.erl:226: :proc_lib.init_p_do_apply/3

また、試しにdebugモードをoffにしてみると "Not Found" という文字列を返していることが分かります。

Not Found

debugモードの切り方

dev.exs Endpointに対する設定で debug_errors という項目があり、これをfalseにするとエラー画面を出さないように変更できます。

config :sample, SampleWeb.Endpoint,
  # Binding to loopback ipv4 address prevents access from other machines.
  # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
  http: [ip: {127, 0, 0, 1}, port: 4000],
  check_origin: false,
  code_reloader: true,
  debug_errors: false, # ここをfalseにすればエラー画面をoffにできる
  secret_key_base: "ZAl1wVszxcApEgi3tyzURc6B+DV/hdPIy0MQ6JIPx9Vd4BCLW0i5W84bLyAqB5CD",
  watchers: [
    # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args)
    esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}
  ]

exceptionをraiseすると基本的に500でレスポンスが返るはずなのに、Ectoがraiseしたエラーだと404で処理されています。今回の記事ではEctoが発したExceptionに対してPhoenixがどのようにハンドリングしているのか、その挙動について解説してみます。

Exceptionに対するPlugの挙動を定義する

例外(Exception)に対する処理についてはカスタムエラーページのドキュメントを読むとなんとなく掴めます。

https://zenn.dev/koga1020/books/phoenix-guide-ja-1-6/viewer/custom_error_pages#カスタムの例外

  • デフォルトでは、PlugとPhoenixはすべての例外を500のエラーとして扱う
  • プラグは Plug.Exception というプロトコルを提供している
  • このプロトコルでは、ステータスをカスタマイズしたり、例外構造体がデバッグエラーページに返すアクションを追加したりできる

というのがポイントです。

ドキュメントの例を1つのコードブロックにするとこんなイメージです。

actionsについて

actionsについてはドキュメント後半のアクション可能なエラーをみると分かりやすいです。開発環境で特定のエラーが発生した場合にseedを実行するボタンを画面上に配置するといったことができます。

defimpl Plug.Exception, for: HelloWeb.SomethingNotFoundError do
  def status(_exception), do: 404

  def actions(_exception) do
    [
      %{
        label: "Run seeds",
        handler: {Code, :eval_file, "priv/repo/seeds.exs"}
      }
    ]
  end
end
# 独自の例外を定義
defmodule HelloWeb.SomethingNotFoundError do
  defexception [:message]
end

# 処理の中で例外をraise
raise HelloWeb.SomethingNotFoundError, "oops"

defimpl Plug.Exception, for: HelloWeb.SomethingNotFoundError do
  # 例外をどのhttp statusに変換するかを定義
  def status(_exception), do: 404
  def actions(_exception), do: []
end

要は、defimpl Plug.Exception, for: <Exception> という具合にプロトコルの実装を追加することで、

  • この例外Aに対しては400で返して
  • この例外Bに対しては404で返して

という具合に、Exceptionに対する挙動を定義できます。

ということは、defimpl Plug.Exception, for: Ecto.NoResultsError とすれば Ecto.NoResultsError 発生時のstatus(404)を定義できるわけです。

自分で defimpl Plug.Exception, for: Ecto.NoResultsError として実装しても良いのですが、Ecto.NoResultsError を404で返したいというのはおよそどの開発者も組むであろう機能のため、 phoenix_ectoプロジェクトで実装され、デフォルトでdepsに追加されているというわけです。

具体的にはphoenix_ectoのplug.exに定義されています。forでdefimpl部分を回しているのでパッと見分かりづらいかもしれませんが、Ectoがraiseするエラーに対して返却するステータスコードをそれぞれ定義しています。

https://github.com/phoenixframework/phoenix_ecto/blob/master/lib/phoenix_ecto/plug.ex

errors = [
  {Ecto.CastError, 400},
  {Ecto.Query.CastError, 400},
  {Ecto.NoResultsError, 404},
  {Ecto.StaleEntryError, 409}
]

excluded_exceptions = Application.get_env(:phoenix_ecto, :exclude_ecto_exceptions_from_plug, [])

for {exception, status_code} <- errors do
  unless exception in excluded_exceptions do
    defimpl Plug.Exception, for: exception do
      def status(_), do: unquote(status_code)
      def actions(_), do: []
    end
  end
end

コードを見てもわかるように、Plug.Exceptionの実装を無視したい場合は exclude_ecto_exceptions_from_plug という設定を追加することで、デフォルトの挙動を変更できます。以下を追加して、 mix deps.compile phoenix_ecto 実行後、NoResultsErrorが発生するページにアクセスすると、 404ではなく "Internal Server Error" (500) が返ってきます。

config :phoenix_ecto,
  exclude_ecto_exceptions_from_plug: [Ecto.NoResultsError]

https://github.com/phoenixframework/phoenix_ecto#configuration

さらに深堀りしてみる

Exception自体は Phoenix.Endpoint.RenderErrors でcatchされ、モジュール内のstatus関数の中でstatusに変換されるようです。

  defp status(:error, error), do: Plug.Exception.status(error)

https://github.com/phoenixframework/phoenix/blob/master/lib/phoenix/endpoint/render_errors.ex#L146

また、Phoenix.Endpoint.RenderErrorsPhoenix.Endpoint をuseすることで読み込まれているという流れのようです(moduledoc参照)

This module is automatically used in Phoenix.Endpoint where it overrides call/2 to provide rendering. Once the error is rendered, the error is reraised unless it is a NoRouteError.

レスポンスボディを変更する

statusが404になるところまでは分かったので、続いてレスポンスの文字列を変更してみます。と言っても、この辺はドキュメントに書いてある通りです。

https://zenn.dev/koga1020/books/phoenix-guide-ja-1-6/viewer/custom_error_pages#errorview

statusが404、かつformatがhtmlなので、PhoenixはErrorViewで "404.html" を描画しようとします。

フォーマットについて

フォーマットはrouterで :accepts に指定したものが利用されます。

  pipeline :browser do
    plug :accepts, ["html"] # ココで指定
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, {SampleWeb.LayoutView, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

phoenixプロジェクト作成時には 404.html.heex がないため、ErrorViewに実装されている template_not_found/2 が呼ばれます。
そこで、404となった場合に読み込まれるテンプレートを追加してあげれば、404発生時のレスポンスを "Not Found" から変更できます。

ちなみに、template_not_found/2 はステータスコードに応じて固定のメッセージを返します。

defmodule SampleWeb.ErrorView do
  use SampleWeb, :view

  # If you want to customize a particular status code
  # for a certain format, you may uncomment below.
  # def render("500.html", _assigns) do
  #   "Internal Server Error"
  # end

  # By default, Phoenix returns the status message from
  # the template name. For example, "404.html" becomes
  # "Not Found".
  def template_not_found(template, _assigns) do
    Phoenix.Controller.status_message_from_template(template)
  end
end

statusとメッセージの一覧は Plug.Conn.Status モジュールの実装を見ると確認できます。

https://github.com/elixir-plug/plug/blob/master/lib/plug/conn/status.ex#L8

<project名>_web/templates/error/404.html.heex を新たに追加したのち、Ecto.NoResultsError を発生させると、テンプレートの内容が表示されます。

# sample_web/templates/error/404.html.heex
<h1>自作した404ページです</h1>

自作した404ページ

まとめ

Exception発生時のエラーハンドリングについておさらいしてみました。なんらかアプリケーション独自でExceptionを定義し、raiseされたタイミングでレスポンスを変化させたいといった場合はこの辺りの実装が必要になってきます。actionsについてもなんらか活用できるかもしれませんね。「こんな便利な使い方があるよ」などもしあればぜひ教えていただけると幸いです。

Discussion