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

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

# この記事は

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

https://qiita.com/h6function/items/77e6fb8fc8961fce1cba


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

Flaskでエラーページを表示する際には、ステータスコードとエラーページを応答する関数をエラーハンドラーに登録することで実現します。

この方法は下記リファレンスページの Custom Error Pages のセクションで紹介されているのでご存じの方も多いかと思います。

https://flask.palletsprojects.com/en/3.0.x/errorhandling/#custom-error-pages

発生したエラーに応じてユーザに促すアクションを出し分けるという点では至極真っ当な実装と思いますが、意地の悪い言い方をすると [HTTP レスポンスステータスコード](https://developer.mozilla.org/ja/docs/Web/HTTP/Status) のうちクライアントエラーとサーバエラーを網羅しない限り、冒頭のシンプルなエラーページがどこかで適用されることになります。
（とはいえ頻出するエラーはある程度絞られるためレアケースを網羅する必要があるか、という点もまた考えるべき部分ではありますが）

## 汎用的なエラーハンドリングの方法は用意されているのでそれを応用

同リファレンスページの Generic Exception Handlers のセクションにはJSONを応答する汎用的な例外ハンドラーの実装例が記載されています。

https://flask.palletsprojects.com/en/3.0.x/errorhandling/#generic-exception-handlers

> ```python
> 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 で動かしてます。

```shell
$ 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種類の方法で実装してみます。

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

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

```python: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();

```

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

:::note info
- エラーハンドラーの記述がスッキリした
- 変更箇所をすべてWerkzeug側に押し込んだのでFlask側では変更を意識する必要がない
:::

::: note warn
- Werkzeugの実装をオーバーライドしているのでモジュールのバージョン更新によって互換性が無くなる可能性あり
:::

## 実装その２: `lambda` でオーバーライドする

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

```python: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();

```

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

# 動かしてみる

動作確認するにあたり以下の `import` とエンドポイントを追加しました。

```python
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 で固定）

それでは起動してアクセスします。

```shell
$ gunicorn app:app
```

まず 400 Bad Request の応答を確認します。

![400_bad_request.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203013/dc8c26ac-75bb-eba2-9400-8191393713e1.gif)

続いて 500 Internal Server Error です。

![500_internal_server_error.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203013/f53987b4-9cd1-cacb-84ac-2bc4dc1e467e.gif)

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

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

# 解説

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

https://qiita.com/h6function/items/77e6fb8fc8961fce1cba

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

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

https://github.com/pallets/werkzeug/blob/main/src/werkzeug/exceptions.py

> ```python: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>"
> 
> ```

> ```python: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"
>         )
> 
> ```