Django rest_framework にてカスタムのレスポンスステータス・ボディを返却する方法について


概要

Django rest_framework を利用して API を設計しています。
アプリケーションでは、別サーバーへ HTTP アクセスしています。
HTTP アクセスが失敗して、なにもハンドリングをしないと、自前の Django アプリケーションで 500 エラーが発生してしまいます。
500 エラーは「うっかり」ででるような類のサーバーエラーのニュアンスがあるように感じられたので、起きうることは予想できるけれど、どうしようもない例外ということで 502 エラー を返そうとしました。
Spring MVC のようなことができることを想定していたのですが、既定の機能では、レスポンスステータスを、502 で返す方法がなさそうなので、カスタムのレスポンスを返却する方法を調査しました。

環境

  • Python: 3.9
  • Django: 3.1.4
  • Django Rest Framework: 3.12.2

実現方法

公式ページのやり方を模倣するだけで実現できました。
rest_framework.exceptions.APIException を継承したクラスを作成します。

  • status_code : ステータスコード
  • detail: レスポンスボディのメッセージ

となりました。

from dataclasses import dataclass
from rest_framework.exceptions import APIException


@dataclass(frozen=True)
class ServerNotWorkedResponse(APIException):
    source_exception: Exception # 元となった例外
    message: str

    status_code = 502
    detail = "外部サーバーへのアクセスに失敗しました。"

このクラスを try catch でハンドルします。
もしも、リクエストする関数をモジュール化している場合、戻り値を Union に設定して、呼び出し元 (アプリケーションレイヤー) で最後の振る舞いを決めることができます。
ライブラリの中でいきなり例外を投げたると、関数の本当の目的 (外部サーバーにアクセスしてレスポンスを利用元に返す) 以外に、「外部サーバーへのアクセスに失敗すると例外が発生する」という機能が入り込み、結果としてモなにか予想外のことがおきる、副作用があるライブラリになります。一番外側の利用元で最後の振る舞いを決める設計にすると、副作用を減らしたライブラリ設計を実現するための手段として活用できます。

import json
from dataclasses import dataclass
import ServerNotWorkedResponse
from typing import Union, cast
from urllib import request
from urllib.error import HTTPError, URLError

@dataclass(frozen=True)
class ExampleDomainResponse:
    id: int,
    name: str

# 呼び出し先
def request_towards_example(id: int) -> Union[ServerNotWorkedResponse, ExampleDomainResponse]:
    req = request.Request(f'https://example.domain.io/resources/{id}')
    try:
        with request.urlopen(req) as res:
            resource = json.loads(res.read())
    except HTTPError as e
        return ServerNotWorkedResponse(source_exception=e, message='サーバーがエラーを返してきました。')
    except URLError as e:
        return ServerNotWorkedResponse(source_exception=e, message='このサーバーは名前解決ができませんでした。URL を解決できるよう設定してください。')
    else:
        return ExampleDomainResponse(
            id=cast(int, resource['id'])
            name=resource['name']
        )

# 最も外側の呼び出し元
response_from_example: Union[ServerNotWorkedResponse, ExampleDomainResponse] = request_towards_example(1)
if isinstance(response_from_example, ServerNotWorkedResponse):
    server_not_worked = cast(ServerNotWorkedResponse, response_from_example)
    logger.error(server_not_worked.source_error)
    logger.error(server_not_worked.message)
    raise server_not_worked