shimada-kの日記

ソフトウェア・エンジニアのブログです

Djangoで例外処理の共通モジュールを考えてみる

この記事は「Django Advent Calendar 2019」の9日目の記事として書かれました。

qiita.com

仕事で絶賛Djangoを使っていますので、今回記事を担当させていただきます。

ところでDjangoで共通のエラー処理ってどう対応していますでしょうか? HTTPのステータスコードの仕分けはどちらかというと枝葉な気がしていて。アプリケーションの至る所で組み込み例外をraiseするのはイケてないのではと思います。

ロジックによって投げるべき例外が違うでしょうし、投げられた例外に応じてユーザに見せるべき内容も違うはずなので、アプリケーションで例外を受けてHTTPステータスを振り分け(またはハンドラの管理)をするレイヤーが必要であろうと思います。

そもそもアプリケーションで発生するエラーはどのような区分けが適切なのでしょうか。まずはそこから考える必要があります。Djangoを使っていることから、なにかしらのWebに関するシステムを対象にしちゃっていいと思いますので、Webのシステムで発生するエラーを考えてみました。

  • ビジネス例外(処理が続行可能)
  • 技術例外(処理が続行不可能)
    • 回復可能
    • 回復不可

今回は上記のような分類をしました。「処理」というのも定義しておく必要があります。

処理:業務ロジックのこと。「続行可能性」は業務ロジック自体に組み込まれるべきかどうか。

上記の定義から「続行可能性を持たせるべき例外」というのは「その例外は業務ロジックの一部としてハンドリングされるべき」という解釈をしています。ではそれぞれについてみていきましょう。

ビジネス例外(処理が続行可能)

業務ロジック上で発生するエラーです。エラーといっても実装からみると正常系の範囲内であろうと判断されるものです。例えば商品や店舗などの検索結果が無いといった事象やログイン・ログアウトなど認証のエラー、フォームのPOSTデータが許可されていないもの(禁止文字、全角文字、ひらがな/カタカナ)など。このカテゴリでは例外は投げてはいけないものでしょう。「例外は、例外的条件に対してのみ使用すべき」1

技術例外(処理が続行不可能)

ビジネス例外と比較して、技術的な理由により引き起こされる例外であるという枠組みです。ゆえに業務ロジック自体には組み込むのはふさわしくないものです。この枠組みの中では実装により回復可能なものと、システム原因でありどうしようもないものが回復不可能なものにそれぞれ分類されると思います。

回復可能

セッションデータや日付日時系の選択が長時間経過することで時間切れになった「コンテキストの期限切れ」、サービス内の内部リンクからではなくURLを直接叩いてアクセスするような「規定ルート以外のリクエスト」、あとはAPI系でよくみる「一時的なネットワーク不通」によるリトライ処理など。例をあげるならそのようなものかなと。いうなれば実装上のガイドがあればシステムとしての完全性はより高まるものの、100%網羅されるべきものとまでは言えないもの、でしょうか。

回復不可

OSやハードウェア(あまりないかも)関連、ネットワークやファイルシステムなどのIO関連、DBの不整合、セキュリティ的に許可されていないリクエストなどが該当するかと。これはもう実装いかんでどうこうできる問題ではなく、どちらかというといち早くシステム管理者に通知を投げて対処されないといけないものであると言えます。

上記の例外分類と最終的にユーザへの見せ方を繋げているロジックと、ハンドラーの実行を管理するクラスです。

import logging
import requests
from django.http import Http404

logger = logging.getLogger(__name__)


class SystemLogicError(Exception):
    def __init__(self, log_prefix):
        self.log_prefix = log_prefix

    def log(self, message):
        logger.exception(self.log_prefix + ':' + message)


class RecoverableError(SystemLogicError):
    def __init__(self, log_prefix, callback):
        self.log_prefix = log_prefix
        self.callback = callback

    def handler(self):
        return self.callback()


class UnrecoverableError(SystemLogicError):
    def finalize(self, func):
        func()


def exception(func):
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except RecoverableError as e:
            e.log()
            return e.handler()
        except UnrecoverableError as e:
            e.log()
            e.finalize()
            raise Http404()
        except Exception:
            raise Http404()

    return wrapper

raiseする側はexceptionをデコレータとして利用します。例としてAPI接続を試みるも、一時的なネットワーク不定によりリトライする場合の処理を上記の仕組みを利用して記述してみます。

♯ 本来リトライはrequests.retryを使うべきでしょうが、一旦サンプルとして。

@exception
def connect_api(*args):
    response_body = {}
    endpoint = 'https://www.google.com/xxx'

    def retries(num=5, host=endpoint):
        def callback():
            nonlocal num
            nonlocal host
            r = {}
            for i in range(num):
                r = requests.get(host)
                if r.status_code == requests.codes.ok:
                    break
                if i == num - 1:
                    raise ConnectionError('サーバ接続エラー')   # ここで投げるのは下位レイヤーの例外
            return r
        return callback

    closure = retries(5, endpoint)

    response_body = requests.get(endpoint)
    if response_body.status_code == requests.codes.ok:
        return response_body
    else:
        raise RecoverableError('ネットワーク接続リトライ:', closure)

https://www.google.com/xxx」という架空のエンドポイントに向けてリクエストを投げて、statu_codeが200でない場合はRecoverableErrorをclosureと一緒にraiseします。closureは5回再送して最終的にエラー状態から回復しない場合はConnectionErrorをraiseします。

回復可能な例外の場合はクロージャーを渡して返り値も受け取れるようにはしてみましたが、少し複雑になってしまった気がします。ただ例外を一元管理できれば、get_object_or_404とかは使わずにいけると思います。

今年も無事アドベントカレンダーを書くことができました。こういった自分自身が直接技術に関わる機会を大切にしていきたいと思います。