회원가입

[DRF] APIException 설명 및 custom exception handler 만들기

NULL 2022-03-19

 

시작하기 앞서...


exceptions handler  왜 커스텀 하는지 알아보자.

 

필자는 이 3가지로 뽑을 수 있을 것 같다.

  1. 에러 모니터링 쉽게 할 수 있도록 로거를 포함하기.
  2. 원하는 예외 처리 Response 객체를 커스텀해 반환하기.
  3. 예상치 못한 예외 처리를 Django 에러, 즉 Server 500 에러가 나지 않도록 방지해준다.

 

 

custom exception_handler 만들기


우선 custom_exception_handler 를 담기 위한 모듈을 생성한다.

(api_exception.py 파일 생성)

 

api_exception.py

from rest_framework.views import exception_handler

def custom_exception_handler(exc, context):
    # Call REST framework's default exception handler first,
    # to get the standard error response.
    response = exception_handler(exc, context)

    # Now add the HTTP status code to the response.
    if response is not None:
        response.data['status_code'] = response.status_code

    return response

이런 식으로 custom_exception_handler를 생성한다.

 

custom_exception_handler로 적용하는 방법은 settings.py 파일 REST_FRAMEWORK 설정에 EXCEPTION_HANDLER를 추가하면 된다.

EXCEPTION_HANDLER는 생성한 모듈을 위치와 custom_exception_handler으로 적용하면 된다.

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'config.authorization.authentication.DefaultAuthentication',
        'rest_framework.authentication.BasicAuthentication',
        'rest_framework.authentication.SessionAuthentication',
    ],
    'EXCEPTION_HANDLER': 'config.exceptions.api_exception.custom_exception_handler'
}

 

 

커스텀하기 전에 APIException Process를 알아보자

 

exception_handler 설명


from rest_framework.views import exception_handler

def custom_exception_handler(exc, context):
    # Call REST framework's default exception handler first,
    # to get the standard error response.
    response = exception_handler(exc, context)

    # Now add the HTTP status code to the response.
    if response is not None:
        response.data['status_code'] = response.status_code

    return response

▶ exc: Exception 클래스가 들어있다.

(특정 APIException 반환)

 

▶ context: request, view url에 접근 했을 때, 정보들이 들어있다.

 

exception_handlerexc, context를 받아서 에러를 처리한다.

exception_handler 함수

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

총 3가지 분기가 일어난다.

만약 에러가 Http404, PermissionDenied, APIException 일 경우를 판단한다.

만약 None을 반환하면 모르는 에러라는 것이다.

그리고 각각 exc에 에러를 넣는다.

(참고, APIException 인스턴스를 생성할 때, exc.detial를 만든다. 아래 추가 설명)

 

APIException -> exc.detail(detail 인스턴스 변수) 정보

APIException 클래스

class APIException(Exception):
    """
    Base class for REST framework exceptions.
    Subclasses should provide `.status_code` and `.default_detail` properties.
    """
    status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
    default_detail = _('A server error occurred.')
    default_code = 'error'

    def __init__(self, detail=None, code=None):
        if detail is None:
            detail = self.default_detail
        if code is None:
            code = self.default_code

        self.detail = _get_error_details(detail, code)

    def __str__(self):
        return str(self.detail)

    def get_codes(self):
        """
        Return only the code part of the error details.

        Eg. {"name": ["required"]}
        """
        return _get_codes(self.detail)

    def get_full_details(self):
        """
        Return both the message & code parts of the error details.

        Eg. {"name": [{"message": "This field is required.", "code": "required"}]}
        """
        return _get_full_details(self.detail)

APIException_get_error_details(detail, code)를 이용하여 APIExcetion 객체를 만들 때 exc.detail , 인스턴스변수 detail을 같이 만든다.

예로 어디에 APIException()을 정의한다면 __init__ 함수를 실행하여, 아무것도 없으니 detailcodedefault_detail, default_code로 _get_error_details 파라미터로 사용되어 결과적인 exc.detail을 만드는 것이다.

 

그렇다면 _get_error_details 함수는 어떤 기능을 할까?

_get_error_details 함수

def _get_error_details(data, default_code=None):
    """
    Descend into a nested data structure, forcing any
    lazy translation strings or strings into `ErrorDetail`.
    """
    if isinstance(data, (list, tuple)):
        ret = [
            _get_error_details(item, default_code) for item in data
        ]
        if isinstance(data, ReturnList):
            return ReturnList(ret, serializer=data.serializer)
        return ret
    elif isinstance(data, dict):
        ret = {
            key: _get_error_details(value, default_code)
            for key, value in data.items()
        }
        if isinstance(data, ReturnDict):
            return ReturnDict(ret, serializer=data.serializer)
        return ret

    text = force_str(data)
    code = getattr(data, 'code', default_code)
    return ErrorDetail(text, code)

만약 _get_error_details 함수 안에 detail로 들어간 파라미터 즉, data 인자가 list 혹은 tuple일 경우, 혹은 dictionary일 경우로 분기 처리된다.

 

직접 list, tuple/dictionary/str인 경우를 예를 들어보자.

 

- list, tuple인 경우

raise APIException(detail=[1, 2, 3], code=714)

exc.detail

[ErrorDetail(string='1', code=714),
 ErrorDetail(string='2', code=714),
 ErrorDetail(string='3', code=714)]

 

- dictionary인 경우

raise APIException(detail={
            "에러1": "에러1 내용입니다.",
            "에러2": "에러2 내용입니다.",
            "에러3": "에러3 내용입니다."
        }, code=714)

exc.detail

{'에러1': ErrorDetail(string='에러1 내용입니다.', code=714),
 '에러2': ErrorDetail(string='에러2 내용입니다.', code=714),
 '에러3': ErrorDetail(string='에러3 내용입니다.', code=714)}

 

- str인 경우

raise APIException(detail="에러1", code=714)

exc.detail

ErrorDetail(string='에러1', code=714)

인스턴스변수 detail은 이렇게 만들어 진다.

 

 

왜 복잡하게 ErrorDetail 클래스로? ErrorDetail 클래스란?


왜 굳이 복잡하게 ErrorDetail 클래스로 반환을 하는 것일까?

그리고 _get_error_details 함수에서 만들어지는 ErrorDetail 클래스가 뭘까?

ErrorDetail 클래스

class ErrorDetail(str):
    """
    A string-like object that can additionally have a code.
    """
    code = None

    def __new__(cls, string, code=None):
        self = super().__new__(cls, string)
        self.code = code
        return self

    def __eq__(self, other):
        r = super().__eq__(other)
        if r is NotImplemented:
            return NotImplemented
        try:
            return r and self.code == other.code
        except AttributeError:
            return r

    def __ne__(self, other):
        return not self.__eq__(other)

    def __repr__(self):
        return 'ErrorDetail(string=%r, code=%r)' % (
            str(self),
            self.code,
        )

    def __hash__(self):
        return hash(str(self))

이 클래스는 간단하게 code라는 것이 추가로 가질 수 있는 문자열(str)이라고 생각하면 된다.

 

이렇게 ErrorDetail로 결과를 반환하는 이유는

APIException 클래스에 정의된 메서드 get_codes와 get_full_details를 실행하기 위해서다.

(APIException 클래스 보기)

 

_get_codes 함수

def _get_codes(detail):
    if isinstance(detail, list):
        return [_get_codes(item) for item in detail]
    elif isinstance(detail, dict):
        return {key: _get_codes(value) for key, value in detail.items()}
    return detail.code

ErrorDetail 객체에서 인스턴스변수 code를 반환한다.

 

_get_full_details 함수

def _get_full_details(detail):
    if isinstance(detail, list):
        return [_get_full_details(item) for item in detail]
    elif isinstance(detail, dict):
        return {key: _get_full_details(value) for key, value in detail.items()}
    return {
        'message': detail,
        'code': detail.code
    }

ErrorDetail 객체에서 인스턴스변수 codedetail(string, 내용은 string 인스턴스 변수에 들어간다) 을 반환한다.

 

 

APIException을 raise 하는 경우 모든 과정 시각화


디테일하게 알아보자!

아래 사진을 보면 어떤 식으로 APIException이  처리되는지 알 수 있다.

크게보기

 

 

response 보여주는 방식


APIException raise하는 경우 exc가 만들어지는 과정을 알게 되었다.

exc가 만들어지면 exc.detail이 생성되는데, 이때 이 exc.detail을 이용해서 exception_handler에서 Response를 생성한다.

(exception_handler 함수 참고: 바로가기)

 

- list, tuple 인 경우

- dictionary 인 경우

- str 인 경우

dictionary 형태로 반환하는 이유는 exception_handler 함수에서 isinstance로 분기처리하고 있기 때문이다.

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

(exception_handler 함수 참고: 바로가기)

 

 

필자의 custom_exception_handler 적용


 

exception 커스텀으로 핸들링하기 위한 사전 작업

- api_exception.py:

    커스텀 exception handler 정의

- custom_exceptions.py:

    APIException 상속받은 커스텀 Exception 정의

- exception_codes.py:

    커스텀 Exception 에 들어갈 에러 코드 상수 정의

 

 api_exception.py

from datetime import datetime

from rest_framework.views import exception_handler
from rest_framework import exceptions
from rest_framework.response import Response

from config.exceptions.custom_exceptions import CustomDictException
from config.settings import logger
from config.exceptions.exception_codes import STATUS_RSP_INTERNAL_ERROR


def custom_exception_handler(exc, context):
    logger.error(f"[CUSTOM_EXCEPTION_HANDLER_ERROR]")
    logger.error(f"[{datetime.now()}]")
    logger.error(f"> exc")
    logger.error(f"{exc}")
    logger.error(f"> context")
    logger.error(f"{context}")

    response = exception_handler(exc, context)

    if response is not None:
        if isinstance(exc, exceptions.ParseError):
            code = response.status_code
            msg = exc.detail
        elif isinstance(exc, exceptions.AuthenticationFailed):
            code = response.status_code
            msg = exc.detail
        elif isinstance(exc, exceptions.NotAuthenticated):
            code = response.status_code
            msg = exc.detail
        elif isinstance(exc, exceptions.PermissionDenied):
            code = response.status_code
            msg = exc.detail
        elif isinstance(exc, exceptions.NotFound):
            code = response.status_code
            msg = exc.detail
        elif isinstance(exc, exceptions.MethodNotAllowed):
            code = response.status_code
            msg = exc.detail
        elif isinstance(exc, exceptions.NotAcceptable):
            code = response.status_code
            msg = exc.detail
        elif isinstance(exc, exceptions.UnsupportedMediaType):
            code = response.status_code
            msg = exc.detail
        elif isinstance(exc, exceptions.Throttled):
            code = response.status_code
            msg = exc.detail
        elif isinstance(exc, exceptions.ValidationError):
            code = response.status_code
            msg = exc.detail
        elif isinstance(exc, CustomDictException):
            """
            APIException dictionary instance process
            For Localization Error Control
            
            아래와 같은 형태 필요
            STATUS_RSP_INTERNAL_ERROR = {
                'code': 'internal-error',
                'default_message': 'unknown error occurred.',
                'lang_message': {
                    'ko': '알 수 없는 오류.',
                    'en': 'unknown error occurred.',
                }
            }
            
            CustomDictException(STATUS_RSP_INTERNAL_ERROR, {"키": "내용", "키": "내용"}) 으로 추가 가능
            code 부분을 추가적인 내용을 넣는 방식으로 사용
            """
            code = exc.detail.get('code')

            if hasattr(context['request'], 'LANGUAGE_CODE'):
                language_code = context['request'].LANGUAGE_CODE
                msg = exc.detail.get(
                    'lang_message'
                ).get(
                    language_code
                )
            else:
                msg = exc.detail.get(
                    'default_message'
                )

            if exc.args[1:]:
                for key, val in exc.args[1].items():
                    response.data[key] = val

            response.data.pop('default_message', None)
            response.data.pop('lang_message', None)
        else:
            code = response.status_code
            msg = "unknown error"


        response.status_code = 200
        response.data['code'] = code
        response.data['message'] = msg
        response.data['data'] = None

        response.data.pop('detail', None)

        return response
    else:
        STATUS_RSP_INTERNAL_ERROR['message'] = STATUS_RSP_INTERNAL_ERROR.pop('default_message', None)
        STATUS_RSP_INTERNAL_ERROR['data'] = None
        STATUS_RSP_INTERNAL_ERROR.pop('lang_message', None)
        return Response(STATUS_RSP_INTERNAL_ERROR, status=200)

 

 

custom_exceptions.py

from rest_framework.exceptions import APIException


class CustomDictException(APIException):
    status_code = 200
    default_detail = 'unknown error.'
    default_code = 'unknown-error'

 

 

exception_codes.py

STATUS_RSP_INTERNAL_ERROR = {
    "code": "internal-error",
    "default_message": "unknown error occurred.",
    "lang_message": {
        "ko": "알 수 없는 오류.",
        "en": "unknown error occurred.",
    }
}

 

 

1. 에러 모니터링 쉽게 할 수 있도록 로거를 포함하기.

에러는 django logger를 이용하여 남겼다.

 

custom_exception_handler 함수에서 에러가 발생하는 경우 매번 로그를 남긴다.

(코드로 가기)

(이 부분이 로그를 남기는 부분이다.)

 

 

2. 원하는 예외 처리 Response 객체를 커스텀해 반환하기.

custom_exception_handler 함수에서 response를 가공시켰다.

(코드로 가기)

 

 

3. 예상치 못한 예외 처리를 Django 에러가 나지 않도록 방지해준다.

예로 아래와 같은 APIView를 생성했다고 하자.

만약 접근하면 아래와 같이 api 예외 처리가 잘 잡힌다.

 

하지만 custom_exception_handler를 적용하지 않았으면 아래 사진과 같이 django 에러 혹은 django DEBUG=False로 했을 경우, Server 500 에러가 나왔을 것이다.

 

 

[작성기간]

2022-03-19 ~ 2022-03-20 19:47 (완)

정리하면서 그냥 무심코 지나갔던 지식을 DRF 코드를 까보고, 알게 재미있었다.

꽤 정리하는데 시간이 오래 걸렸고 힘들었지만 보람찬 공부였다!!

0 0
Django
WAS(Web Application Server) Django를 이용한 프로젝트 혹은 관련 지식을 쓰는 공간 입니다!
Yesterday: 677
Today: 169