Django Rest Framework で 404 NotFound の際のエラーメッセージが変更出来なかったので対応したメモ

発生した問題

  • Djangoで404のときのエラーメッセージをデフォルトから変更したかったが、変更されなかった。

前提

Django : 2.x
djangorestframework: 3.9.x
djangorestframework-jsonapi: 2.4.0 ( これは最新でも同じソースコードになってた)

また、 GitHub - django-json-api/django-rest-framework-json-api: JSON API support for Django REST Framework に則って、 少なくとも以下のREST_FRAMEWORK の設定を行ってる。

REST_FRAMEWORK = {
    'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler'
}

再現した手順

元々のコードとしては以下のようなコードであった。
※ なお、ここで紹介するのは実コードでは無く、今回と同じ現象が発生するサンプルコード

from django.http import Http404

def some_method(self):
    object = self.get_some_object()

    if object is None:
        raise Http404(detail='データにアクセスする権限がありません。')

    return object

この場合、Django Rest Frameworkで設定した exception_handler にて 適切にハンドリングされて 以下のようにエラーが返った。

{"errors": [{"detail": "見つかりませんでした。", "source": {"pointer": "/data"}, "status": "404"}]}

本来は detail に設定したように 以下のメッセージが返ると期待していたが、何故か指定されなかった。

{"errors": [{"detail": "データにアクセスする権限がありません。", "source": {"pointer": "/data"}, "status": "404"}]}

対応した手順

from rest_framework.exceptions import NotFound

def some_method(self):
    object = self.get_some_object()

    if object is None:
        raise NotFound(detail='データにアクセスする権限がありません。')

    return object

exception の クラスを Http404 クラス から NotFound に変える事で正常稼働した。

何故そうなったのか

NotFound クラスについて

そもそも、 見つかりませんでした。 はどこが決めているのか? を考える必要がある。

これは、 実は NotFound() クラスが持つ デフォルトの エラーメッセージである。

github.com

raise NotFound() とした時は 上記ロジックの default_detail のプロパティが各実行環境に応じて localize されてエラーメッセージが決まる。

NotFound() クラス と言わず rest_framework.exceptions 内に定義された APIException クラスを継承したクラス全般において、上記のような default_detail プロパティを持ち、もし指定されなければ デフォルトエラーメッセージが指定されて、指定されれば 上書きされる。

これは、 APIExceptionのコンストラクタから理解出来る.

if detail is None:
    detail = self.default_detail

なので raise Http404('hogehoge') とした時は 'hogehoge' が無視されて、 NotFound() のエラーメッセージがデフォルトで入るみたいなロジックになっているのでは? みたいに当たりを付けた

エラー時のレスポンスについて

では、実際に エラーハンドリングがどのようにされて、結果、以下のレスポンスはどのように決まっているのかを考える。

{"errors": [{"detail": "***", "source": {"pointer": "/data"}, "status": "404"}]}

これは、exception_handler のロジックを読むことで理解が出来た。

まず、 setting に追加した exception_hanlder の所だが、以下となっている。

github.com

def exception_handler(exc, context):
    # Import this here to avoid potential edge-case circular imports, which
    # crashes with:
    # "ImportError: Could not import 'rest_framework_json_api.parsers.JSONParser' for API setting
    # 'DEFAULT_PARSER_CLASSES'. ImportError: cannot import name 'exceptions'.'"
    #
    # Also see: https://github.com/django-json-api/django-rest-framework-json-api/issues/158
    from rest_framework.views import exception_handler as drf_exception_handler

    # Render exception with DRF
    response = drf_exception_handler(exc, context)
    if not response:
        return response

    # 略 ...
    return some_code()

rest_framework_json_api.exceptions.exception_handler から rest_framework.views.exception_handler を呼び出して、 そこで第一回目の判定を行っている。

まず一個目のif分が True なのか False なのか、ここを考える事で切り分けができていきそうだ。 当の rest_framework.views.exception_handler のロジックはここにある。

github.com

def exception_handler(exc, context):
    """
    Returns the response that should be used for any given exception.
    By default we handle the REST framework `APIException`, and also
    Django's built-in `Http404` and `PermissionDenied` exceptions.
    Any unhandled exceptions may return `None`, which will cause a 500 error
    to be raised.
    """
    if isinstance(exc, Http404):
        exc = exceptions.NotFound()
    elif isinstance(exc, PermissionDenied):
        exc = exceptions.PermissionDenied()

    if isinstance(exc, exceptions.APIException):
        headers = {}
        if getattr(exc, 'auth_header', None):
            headers['WWW-Authenticate'] = exc.auth_header
        if getattr(exc, 'wait', None):
            headers['Retry-After'] = '%d' % exc.wait

        if isinstance(exc.detail, (list, dict)):
            data = exc.detail
        else:
            data = {'detail': exc.detail}

        set_rollback()
        return Response(data, status=exc.status_code, headers=headers)

    return None

という事で... 大体これで、現象の発生理由が理解出来た。

発生してた元々のコードは raise Http404 としてたが、 rest_framework.views.exception_handler は そのクラスが throw された時は内部で raise NotFound() に置き換える。

これにより、先述の通り Http404 に指定されたエラーメッセージは破棄されて、 NotFound クラスのデフォルトのメッセージがあたっていた、というわけだ。

NotFoundクラスは これも先述の通り APIExceptionの子クラスなので if isinstance(exc, exceptions.APIException): の中に入り、結果的に エラーメッセージは デフォルトのNotFound() のエラーメッセージとなっていた。

逆に成功したパターンは if isinstance(exc, Http404): は False だし elif isinstance(exc, PermissionDenied): も該当しない。

結果 if isinstance(exc, exceptions.APIException): に該当して、何もオーバーライドされる事もなく、正常に動作した、という事だった。

ちなみに理論上 raise APIException('データにアクセスする権限がありません。', status_code=404) とかでもきっと カスタムエラーメッセージの404 エラーがはけそう。

ということで、微妙なこだわりの為 はまった、という事でしたw