LoginSignup
2
2

Flask + Jinja2 で汎用のエラーページをカスタマイズする(モジュールのメンバ関数をオーバーライドする編)

Last updated at Posted at 2024-04-30

Flaskで汎用のエラーページを表示する処理がなかったので、モジュール標準のエラーページ生成処理をベースに実装してみました。

この記事は

以下記事の別実装編です。

Flask & Jinja2 には汎用的なエラーページを作成する機能がない

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

実装

今回は2種類の方法で実装してみます。

実装その1: 普通に関数作ってオーバーライドする

Werkzeugの HTTPException クラスが持つ get_description()get_body() をベースに同名関数を実装しオーバーライドしました。

app.py
from flask import Flask, render_template
from markupsafe import escape, Markup
from werkzeug.exceptions import HTTPException


app = Flask(__name__)


def get_description(self, environ=None, scope=None) -> str:
    if self.description is None:
        description = ""
    else:
        description = self.description
    return escape(description).replace("\n", Markup("<br>"))


def get_body(self, environ=None, scope=None) -> str:
    return render_template(
        'error.j2',
        code=self.code, name=escape(self.name), description=self.get_description(environ)
    )


HTTPException.get_description = get_description
HTTPException.get_body = get_body
    

@app.errorhandler(HTTPException)
def generic_http_error(e):
    return e.get_response();

両関数の引数である environscope は使用していませんが、インターフェースを統一する意味で残しています。

  • エラーハンドラーの記述がスッキリした
  • 変更箇所をすべてWerkzeug側に押し込んだのでFlask側では変更を意識する必要がない
  • Werkzeugの実装をオーバーライドしているのでモジュールのバージョン更新によって互換性が無くなる可能性あり

実装その2: lambda でオーバーライドする

上記で実装した関数はシンプルかつ再利用しないため lambda で実装してみたのがこちらです。

app.py
from flask import Flask, render_template
from markupsafe import escape, Markup
from werkzeug.exceptions import HTTPException


app = Flask(__name__)

HTTPException.get_description = lambda self, environ=None, scope=None: escape("" if self.description is None else self.description).replace("\n", Markup("<br>"))
HTTPException.get_body = lambda self, environ=None, scope=None: render_template('error.j2', code=self.code, name=escape(self.name), description=self.get_description(environ))


@app.errorhandler(HTTPException)
def generic_error(e):
    return e.get_response();

ポイントは実装その1と同じですが lambda で1行にまとめた代償として可読性が低いところは賛否両論ありそうです。

動かしてみる

動作確認するにあたり以下の 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 の応答を確認します。

400_bad_request.gif

続いて 500 Internal Server Error です。

500_internal_server_error.gif

エラー内容がちゃんと差し代わっていることが確認できました。

ちなみにエラー画面のデザインは某特務機関のシステムを参考にしています。
これでもかってくらい恐怖心を煽るエラーページな気がしなくもないですが、一定の層にとっては脳汁ドバドバ出ること請け合いでしょう。

解説

以下に細かく書いてあるのでこっちは簡単にまとめます。

エラーレスポンスは Werkzeug モジュールが持っている HTTPException クラスの get_response() を呼び出して生成しており、レスポンスボディはその中で get_body()get_description() を呼び出して生成しています。

今回オーバーライドしている実装はこれらメンバ関数の処理がベースとなっています。

werkzeug/src/werkzeug/exceptions.py
    def 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>"

werkzeug/src/werkzeug/exceptions.py
    def 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"
        )

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2