Flaskで汎用のエラーページを表示する処理がなかったので、モジュール標準のエラーページ生成処理をベースに実装してみました。
そもそもエラーページを任意のテンプレートで作成すると何が良い?
上記はFlask作成したWebアプリケーションがデフォルトで返却する 404 Not Found のページです。
非常にシンプルかつ既視感があり、ブラウジングの最中に出てきた場合は恐らくですが「またこいつか」と若干の苛立ちを感じさせてくれることと思います。
試しに他のWebサービスの 404 Not Found ページと見比べてみましょう。
これらがブラウジングの最中に出てきた場合どのような印象を持つでしょうか?
個人的には以下のような感覚を抱きます。
- サービスロゴが記載されていたりデザインが他ページと統一されていたり(あるいは遊び心があったり)で、エラーページが表示された際のストレスや拒否感が低減される
- 次に何をすれば良いかが分かる(ブラウザバック以外の導線を提供している)
このようなポイントはWebサービスを継続的に利用してもらう上で貢献するはずです。
Flask & Jinja2 には汎用的なエラーページを作成する機能がない
話をFlaskに戻します。
Flaskでエラーページを表示する際には、ステータスコードとエラーページを応答する関数をエラーハンドラーに登録することで実現します。
この方法は下記リファレンスページの Custom Error Pages のセクションで紹介されているのでご存じの方も多いかと思います。
発生したエラーに応じてユーザに促すアクションを出し分けるという点では至極真っ当な実装と思いますが、意地の悪い言い方をすると HTTP レスポンスステータスコード のうちクライアントエラーとサーバエラーを網羅しない限り、冒頭のシンプルなエラーページがどこかで適用されることになります。
(とはいえ頻出するエラーはある程度絞られるためレアケースを網羅する必要があるか、という点もまた考えるべき部分ではありますが)
汎用的なエラーハンドリング自体は用意されているのでそれを応用
同リファレンスページの Generic Exception Handlers のセクションにはJSONを応答する汎用的な例外ハンドラーの実装例が記載されています。
from flask import json from werkzeug.exceptions import HTTPException @app.errorhandler(HTTPException) def handle_exception(e): """Return JSON instead of HTML for HTTP errors.""" # start with the correct headers and status code from the error response = e.get_response() # replace the body with JSON response.data = json.dumps({ "code": e.code, "name": e.name, "description": e.description, }) response.content_type = "application/json" return response
これを応用し、JSONで返却している情報をテンプレートに埋め込んで汎用のカスタムエラーページを作成していきます。
開発環境
M1 Macbook で動かしてます。
$ uname -a
Darwin macbook.local 23.4.0 Darwin Kernel Version 23.4.0: Fri Mar 15 00:12:41 PDT 2024; root:xnu-10063.101.17~1/RELEASE_ARM64_T8103 arm64
$ python --version
Python 3.12.2
$ pip freeze | grep -e '^Flask==' -e '^Jinja2==' -e '^Werkzeug=='
Flask==3.0.3
Jinja2==3.1.3
Werkzeug==3.0.2
実装
以下のような形で実装してみました。
from flask import Flask, render_template
from markupsafe import escape, Markup
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
@app.errorhandler(HTTPException)
def custom_error(e):
code = e.code
name = escape(e.name)
if e.description is None:
description = ""
else:
description = e.description
description = escape(description).replace("\n", Markup("<br>"))
return render_template(
'error.j2',
code=code, name=name, description=description
), code
「Generic Exception Handlers をベースに実装するなら code
、 name
、 description
を render_templete()
でテンプレートに渡すだけで良いのでは?」と思われそうですが、これは今回 Werkzeug
の HTTPException
クラスが持っているエラーレスポンスのボディ生成処理を拡張する形で実装することに拘っているためです。
詳細は後ほど解説します。
動かしてみる
動作確認するにあたり以下の import
とエンドポイントを追加しました。
from flask import abort
from werkzeug.exceptions import default_exceptions
# ...省略...
@app.get('/<int:code>')
def error_page(code):
if code not in [k for k in default_exceptions.keys()]:
code = 500
abort(code)
こうすることで例えば http://localhost:8000/403 にリクエストすると 403 Forbidden を応答、 http://localhost:8000/502 にリクエストすると 502 Bad Gateway を応答、といった具合に任意のエラーを発生させることができます。
(なお999など存在しないエラーコードがリクエストされたら 500 Internal Server Error で固定)
それでは起動してアクセスします。
$ gunicorn app:app
まず 400 Bad Request の応答を確認します。
続いて 500 Internal Server Error です。
エラー内容がちゃんと差し代わっていることが確認できました。
ちなみにエラー画面のデザインは某特務機関のシステムを参考にしています。
これでもかってくらい恐怖心を煽るエラーページな気がしなくもないですが、一定の層にとっては脳汁ドバドバ出ること請け合いでしょう。
解説
Werkzeug のエラーレスポンス取得の流れを理解する
Flaskリファレンスの Generic Exception Handlers にもあるよう、エラーレスポンスは Werkzeug
モジュールが持っている HTTPException
クラスの get_response()
を呼び出して生成しているのでこれを確認します。
werkzeug/src/werkzeug/exceptions.pydef get_response( self, environ: WSGIEnvironment | WSGIRequest | None = None, scope: dict[str, t.Any] | None = None, ) -> Response: """Get a response object. If one was passed to the exception it's returned directly. :param environ: the optional environ for the request. This can be used to modify the response depending on how the request looked like. :return: a :class:`Response` object or a subclass thereof. """ from .wrappers.response import Response as WSGIResponse # noqa: F811 if self.response is not None: return self.response if environ is not None: environ = _get_environ(environ) headers = self.get_headers(environ, scope) return WSGIResponse(self.get_body(environ, scope), self.code, headers)
get_response()
が返すのは WSGIResponse
インスタンスですが、今フォーカスするのはテンプレートの組み立てに必要なパラメータの生成部分のみなので get_body()
を確認します。
werkzeug/src/werkzeug/exceptions.pydef get_body( self, environ: WSGIEnvironment | None = None, scope: dict[str, t.Any] | None = None, ) -> str: """Get the HTML body.""" return ( "<!doctype html>\n" "<html lang=en>\n" f"<title>{self.code} {escape(self.name)}</title>\n" f"<h1>{escape(self.name)}</h1>\n" f"{self.get_description(environ)}\n" )
self.code
については特に加工などせずにHTMLの構成要素として埋め込まれており、対して self.name
は escape()
を噛ませて埋め込まれていることが見て取れます。
そして self.description
はこのメソッドでは使用していないので、次に確認するのは get_description()
です。
werkzeug/src/werkzeug/exceptions.pydef get_description( self, environ: WSGIEnvironment | None = None, scope: dict[str, t.Any] | None = None, ) -> str: """Get the description.""" if self.description is None: description = "" else: description = self.description description = escape(description).replace("\n", Markup("<br>")) return f"<p>{description}</p>"
ざっくりまとめると self.description
の扱いは以下のような感じです。
-
self.description
がNone
の場合のみ空文字に変換- 余談: Werkzeug 2.x のどこかのタイミングでは
elif
でもう一つ条件があったのですが変更されたみたいです
- 余談: Werkzeug 2.x のどこかのタイミングでは
- 改行を含む場合は
<br>
タグに置換 -
<p>
タグで修飾して返却
ここまでを踏まえて Werkzeug のエラーレスポンスと照らし合わせる
get_body()
と get_description()
で生成したボディはこんな具合にクライアントへ返却されていることが確認できました。
その結果、冒頭のシンプルなエラーページがレンダリングされている訳ですね。
改めて実装したコードを見てみる
以下に def custom_error(e)
のみ抜粋してコメントつけました。
get_body()
と get_description()
がベースに実装されていることが分かるかと思います。
def custom_error(e):
# `get_body()` をベースに実装:
# - `code` は加工なし、
# - `name` は `escape()` を経由
code = e.code
name = escape(e.name)
# `get_description()` をベースに実装:
# - `description` が `None` の場合は空文字に変換
# - 改行を含む場合は `<br>` タグに置換してから `escape()` を経由
if e.description is None:
description = ""
else:
description = e.description
description = escape(description).replace("\n", Markup("<br>"))
# 用意したエラーページのJinja2テンプレートにパラメータを適用
return render_template(
'error.j2',
code=code, name=name, description=description
), code
おまけ
以下にて別の形の実装例も投稿しました。