Flask + Jinja2 で汎用のエラーページをカスタマイズする（自前実装編）

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

# そもそもエラーページを任意のテンプレートで作成すると何が良い？

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203013/345c6f72-ccca-7a6b-02d3-3f515f6e1654.png)

上記はFlask作成したWebアプリケーションがデフォルトで返却する 404 Not Found のページです。

非常にシンプルかつ既視感があり、ブラウジングの最中に出てきた場合は恐らくですが「またこいつか」と若干の苛立ちを感じさせてくれることと思います。

試しに他のWebサービスの 404 Not Found ページと見比べてみましょう。

Amazon
<img width="auto" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203013/455aed29-7d7f-8f3c-4448-eb80b9e4bed7.png" />

Qiita
<img width="auto" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203013/cd5fbe83-0875-f656-639f-a5be3b23a8c2.png" />

GutHub
<img width="auto" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203013/2067ece0-7e85-94d1-58c3-be2ac50478b1.png" />

これらがブラウジングの最中に出てきた場合どのような印象を持つでしょうか？

個人的には以下のような感覚を抱きます。

- サービスロゴが記載されていたりデザインが他ページと統一されていたり（あるいは遊び心があったり）で、エラーページが表示された際のストレスや拒否感が低減される
- 次に何をすれば良いかが分かる（ブラウザバック以外の導線を提供している）

このようなポイントはWebサービスを継続的に利用してもらう上で貢献するはずです。

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

話をFlaskに戻します。

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
```

# 実装

以下のような形で実装してみました。

```python:app.py
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` とエンドポイントを追加しました。

```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)

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

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

# 解説

## Werkzeug のエラーレスポンス取得の流れを理解する

Flaskリファレンスの Generic Exception Handlers にもあるよう、エラーレスポンスは `Werkzeug` モジュールが持っている `HTTPException` クラスの `get_response()` を呼び出して生成しているのでこれを確認します。

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

> ```python:werkzeug/src/werkzeug/exceptions.py
>     def 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()` を確認します。

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

`self.code` については特に加工などせずにHTMLの構成要素として埋め込まれており、対して `self.name` は `escape()` を噛ませて埋め込まれていることが見て取れます。

そして `self.description` はこのメソッドでは使用していないので、次に確認するのは `get_description()` です。

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

ざっくりまとめると `self.description` の扱いは以下のような感じです。

- `self.description` が `None` の場合のみ空文字に変換
    - 余談： Werkzeug 2.x のどこかのタイミングでは `elif` でもう一つ条件があったのですが変更されたみたいです
- 改行を含む場合は `<br>` タグに置換
- `<p>` タグで修飾して返却

## ここまでを踏まえて Werkzeug のエラーレスポンスと照らし合わせる

`get_body()` と `get_description()` で生成したボディはこんな具合にクライアントへ返却されていることが確認できました。

その結果、冒頭のシンプルなエラーページがレンダリングされている訳ですね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203013/a60f43a6-84af-bd50-826d-96fb8e206337.png)

## 改めて実装したコードを見てみる

以下に `def custom_error(e)` のみ抜粋してコメントつけました。
`get_body()` と `get_description()` がベースに実装されていることが分かるかと思います。

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

```

# おまけ

以下にて別の形の実装例も投稿しました。

https://qiita.com/h6function/items/7926a6fcb19decf98f79