Ecto.NoResultsError発生時にPhoenixはどのようにして404を返しているのか
Phoenixで Repo.get!/2
で存在しないidを指定した場合など Ecto.NoResultsError
がraiseされた場合に、Phoenixでは500ではなく404でレスポンスが返ってきます。
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" という文字列を返していることが分かります。
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)に対する処理についてはカスタムエラーページのドキュメントを読むとなんとなく掴めます。
- デフォルトでは、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するエラーに対して返却するステータスコードをそれぞれ定義しています。
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]
さらに深堀りしてみる
Exception自体は Phoenix.Endpoint.RenderErrors
でcatchされ、モジュール内のstatus関数の中でstatusに変換されるようです。
defp status(:error, error), do: Plug.Exception.status(error)
また、Phoenix.Endpoint.RenderErrors
は Phoenix.Endpoint
をuseすることで読み込まれているという流れのようです(moduledoc参照)
This module is automatically used in
Phoenix.Endpoint
where it overridescall/2
to provide rendering. Once the error is rendered, the error is reraised unless it is a NoRouteError.
レスポンスボディを変更する
statusが404になるところまでは分かったので、続いてレスポンスの文字列を変更してみます。と言っても、この辺はドキュメントに書いてある通りです。
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
モジュールの実装を見ると確認できます。
<project名>_web/templates/error/404.html.heex
を新たに追加したのち、Ecto.NoResultsError
を発生させると、テンプレートの内容が表示されます。
# sample_web/templates/error/404.html.heex
<h1>自作した404ページです</h1>
まとめ
Exception発生時のエラーハンドリングについておさらいしてみました。なんらかアプリケーション独自でExceptionを定義し、raiseされたタイミングでレスポンスを変化させたいといった場合はこの辺りの実装が必要になってきます。actionsについてもなんらか活用できるかもしれませんね。「こんな便利な使い方があるよ」などもしあればぜひ教えていただけると幸いです。
Discussion