[FastAPI X Python] 실무에서 바로 쓰는 FastAPI 전역 에러 처리

봄도둑·2024년 9월 7일
0

FastAPI X Python

목록 보기
2/2

예외 처리는 언제나 중요합니다.

클라이언트의 요청이 무슨 이유로 실패했고, 실패 후 어떻게 재시도해야 하는지를 알려줄 수 있으며, 예외 처리를 제대로 하지 않는다면 백엔드의 코드는 매 에러 발생 시 에러에 대한 정보를 담은 response를 코드 레벨에서 작성하게 됩니다.

이번에 해볼 작업은 FastAPI에서 spring의 HandlerExceptionResolver처럼 전역으로 사용할 수 있는 예외 처리입니다. 제가 실무에서 구현한 예외 처리 로직을 fastAPI의 exception handler와 함께 설명했습니다.

본격적으로 해당 내용을 다루기에 앞서 FastAPI에서 다루는 HttpException에 대해 간단하게 다뤄보겠습니다.

1. HttpException

여기 상품을 조회하는 간단한 handler가 있습니다.

@router.get("/{product_id}", status_code=200, response_model=ProductResponse)
def get_product_handler(
    product_id: int,
    product_repo: ProductRepository = Depends(),
):
    product: Product = product_repo.get_product_by_id(product_id=product_id)
    return ProductResponse.from_orm(product)

이 케이스에서 product_id에 해당하는 상품이 없다면 클라이언트에게 상품에 대한 리소스를 조회할 수 없다는 예외를 발생 시켜야 합니다.

이에 대해 서버는 발생한 에러가 무엇인지 정의하고, 에러에 대한 응답의 json을 직접 작성해야 하는 번거로움이 있습니다.

이에 대해 FastAPI는 HttpException이라는 오류 응답을 간편하게 처리하고 반환할 수 있는 예외 클래스를 제공합니다.

HttpException은 다음과 같은 주요 특징을 가지고 있습니다.

  1. 발생한 에러에 대한 http status code를 제공할 수 있습니다. 이는 status_code 파라미터로 전달합니다.
    • ex) 400(bad request), 404(not found), 403(forbidden) 등
  2. 에러 발생에 따른 상세 메세지를 작성할 수 있습니다. 이는 detail 파라미터로 전달합니다. string, dict로 전달 가능합니다.
  3. response 헤더에 정보를 추가할 수 있습니다. 이는 headers 파라미터에 dict로 전달합니다.

이러한 주요 특징을 기반으로 product가 존재하지 않는다는 에러를 발생시켜 보겠습니다.

from fastapi import APIRouter, Depends, HTTPException

#...

@router.get("/{product_id}", status_code=200, response_model=ProductResponse)
def get_product_handler(
    product_id: int,
    product_repo: ProductRepository = Depends(),
):
    product: Product = product_repo.get_product_by_id(product_id=product_id)
    
    if product is None:
	      raise HttpException(
	          status_code=404,
	          detail="Product not Found",
	          headers={"reason": "invalid product id"}
	      )
	      
    return ProductResponse.from_orm(product)

이제 전역에서 예외를 감지하고 사용자 정의 에러를 응답하도록 처리 해봅시다.

2. exception handler

FastAPI의 exception handler는 애플리케이션 상에서 발생하는 예외를 처리하고 handler가 지정한 예외에 대해 커스텀한 응답을 제공하거나 예외 발생 시 처리할 로직들을 수행합니다.

서버에서 발생하는 각종 예외는 try, except 구문으로 처리가 가능하지만 우리는 전역에서 발생한 예외를 알맞게 처리하기 위해 exception handler를 사용합니다.

FastAPI에서 exception handler가 동작하는 구조는 아래 이미지와 같습니다.


(출처 : 나)

client의 요청 사항을 처리하던 중 A Exception을 상속 받은 A_sub Exception이 발생했습니다. 이 때 FastAPI는 먼저 선언된 execption handler를 탐색합니다. 탐색 조건은 가장 구체적으로 일치하는 예외 클래스가 지정된 handler로 A_sub Exception Handler를 호출합니다. 만약 A_sub Exception handler가 없다면 그 다음으로 구체적인 A Exception handler를 호출하게 됩니다.

A_sub Exception handler를 호출해 클라이언트에게 전달할 응답 및 handler 내부 로직을 수행하고 응답 결과를 FastAPI 애플리케이션에 전달, FastAPI 애플리케이션은 해당 결과를 Client에게 다시 전달하는 구조를 가집니다.

이제 실제 동작하는 간략한 코드를 통해 exception handler를 구현해보고 각 옵션에 대해 살펴보겠습니다.

import logging
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()

class AException(Exception): 
	# ...

class ASubException(AException):
	# ...

@app.exception_handler(ASubException)
async def a_sub_exception_handler(request: Request, exc: ASubException) -> JSONResponse:
    logging.error(str(exc))
    print("!!!!!! request url path: ", request.url.path)
    print("!!!!!! request method: ", request.method)
    print("!!!!!! request path_params: ", request.path_params)
    print("!!!!!! request client host: ", request.client.host)
    
    return JSONResponse(
        status_code=exc.status_code,
        content={"message": exc.message}
     )
	

AException은 Exception 클래스를 상속 받은 예외 클래스이며, ASubExcepiton은 AException을 상속받은 예외 클래스입니다. (둘의 상세 구현은 생략했습니다.)

우리는 ASubException의 에러에 대해 전역에서 감지하고 처리할 handler를 생성할 겁니다. 이를 위해서는 FastAPI()에서 제공하는 exception_handler 데코레이터를 사용합니다.

exception_handler 데코레이터에는 exc_class_or_status_code 파라미터를 넘겨주게 되는데 이름에서도 알 수 있듯이 예외 클래스 혹은 status code를 넘겨줍니다.

예외 클래스를 넘겨주게 될 경우에는 해당 예외가 발생했을 때 동작하는 handler로서 역할을 수행하며 status_code를 넘겨주게 되면 http 상태 코드에 따라 발생하는 handler를 생성할 수 있습니다.

아래의 예제 코드는 status_code가 404일 경우 전역에서 처리할 수 있는 handler입니다. 예외 클래스 매핑보다 http 상태 코드에 맞춘 예외 처리가 필요하다면 예제처럼 exception handler를 작성해주시면 됩니다.

@app.exception_handler(exc_class_or_status_code=404)
async def not_found_resource_handler(request: Request, exc: Exception) -> JSONResponse:
    #...

exception_handler는 2개의 파라미터를 받습니다. 인자는 다음과 같습니다.

  • request : fastAPI의 Request 객체로 호출한 요청에 대한 정보를 가지고 있습니다.
  • exc : 발생한 예외 클래스의 객체로 @app.exception_handler 데코레이터에서 지정한 예외 클래스를 받습니다.

보통 예외 처리 시 단순히 예외에 대한 응답만 처리하는 것이 아니라 에러에 대한 logging 처리도 같이 하게 되는데 이 때 request 파라미터를 통해 어떤 요청에서 문제가 발생했는지 정보를 확인하고 해당 정보를 log로 적재합니다. request는 클라이언트 요청에 대한 모든 정보를 가지고 있지만 주로 사용하는 옵션은 다음과 같습니다.

  • request.url.path : 요청한 url 경로 정보를 나타냅니다.
    • ex) api/v1/product
  • request.method : 요청한 API의 http 메소드를 나타냅니다.
    • ex) POST , GET , PUT
  • path_params : url path에 지정한 파라미터의 정보를 dict 타입으로 표시합니다.
    • ex) {'id': '12'}
  • query_params: 요청의 query parameter의 정보를 표시합니다.
    • ex) id=13
  • request.client.host : 요청한 클라이언트의 호스트 ip를 표시합니다.
    • ex) 127.0.0.1

3. 실무에서 써보는 exception 구조

우리는 예외 처리의 관심사를 분리하는 작업을 진행할 예정입니다. exception 패키지를 생성하고 예외 클래스를 정의할 exception.py과 handler의 동작을 정의할 handler.py를 작성합니다.

이제 exception.py에서 사용할 예외 클래스와 예외 클래스에 담을 ErrorCode Enum을 정의합니다.

# exception.py
from enum import Enum
from fastapi import HTTPException

class ErrorCode(Enum):
    # Bad Request
    INVALID_INPUT = 1100

    # Unauthorized
    EMPTY_TOKEN = 2001
    TOKEN_EXPIRED = 2002
    INVALID_TOKEN = 2003
    DENIED_PERMISSION = 2004

    # Not Found
    RESOURCE_NOT_FOUND = 4001

    # Internal Server Error
    UNEXPECTED_ERROR = 9000
    CONNECTION_ERROR = 9001
    MODEL_TIMEOUT = 9101

class OperatedException(HTTPException):
    def __init__(self, status_code: int, error_code: ErrorCode, detail: str):
        super().__init__(status_code=status_code, detail=detail)
        self.code = error_code

ErrorCode는 예외가 발생했을 때 어떤 예외인지 클라이언트와 함께 정의한 예외 코드입니다. 클라이언트는 ErrorCode를 기반으로 어떠한 이유로 요청이 실패했는지 상세하게 확인할 수 있습니다. 이전에 제가 작성했던 Spring boot의 예외 처리에서 사용한 ResponseCode와 유사한 역할을 수행합니다.

사용자 정의 예외 클래스인 OperatedException은 HttpException을 상속 받은 사용자 정의 예외 클래스입니다. HttpException을 상속 받은 이유는 다음과 같습니다.

  1. 해당 예외 처리를 수행할 때 HttpException과 같은 처리를 하면 되기 때문에 FastAPI가 익숙하지 않은 여러 개발자들이 함께 개발할 때 보다 쉽게 예외를 발생시킬 수 있습니다.
  2. FastAPI의 표준에 따랐기 때문에 코드 상에서 예외 처리 시 FastAPI의 코드 작성 규칙을 일관적으로 유지할 수 있다고 생각했습니다.
  3. Error Response에 최적화 되어 있기 때문에 Exception 발생 시 넘겨줄 정보에 대한 정의가 쉬웠습니다.

우리는 OperatedException에 클라이언트와 사전에 정의한 error_code값만 넘겨주면 HttpException 처럼 사용할 수 있습니다.

이제 handler를 작성해보겠습니다. handler는 handler.py에 작성했습니다.

# handler.py
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from exception.exception import OperatedException, ErrorCode
from service.log import log_service

def set_error_handlers(app: FastAPI):
    
    # 클라이언트의 요청 처리에 따라 발생한 예외는 OperatedException로 처리 
    @app.exception_handler(OperatedException)
    async def operated_exception_handler(request: Request, exc: OperatedException) -> JSONResponse:
        # log service를 통해 error 발생 로그 적재
        log_service.insert_client_log(request=request)
        
        # error response
        return JSONResponse(
            status_code=exc.status_code,
            content={"code": exc.code.value, "reason": exc.code.name, "message": exc.detail}
        )
			
		
		# 그 외 요청을 처리하다가 서버에서 예상치 못한 예외가 발생한 예외 처리
		@app.exception_handler(Exception)
    async def server_side_exception_handler(request: Request, exc: Exception):
        # log 적재
        log_service.insert_server_log(request=request, exception=exc)
        
        # error response
        return JSONResponse(
            status_code=500,
            content={"code": ErrorCode.UNEXPECTED_ERROR.value}
        )

handler는 set_error_handlers를 통해 관리합니다. set_error_handlers는 FastAPI를 인자로 받습니다.

각 error handler는 log_service를 통해 log를 적재하고 콘솔에 기록합니다.

에러 처리는 클라이언트의 요청이 문제인 것인지, 서버가 처리에 실패한 것인지 두 가지로 나눠서 처리했습니다.

클라이언트의 요청에 따라 발생하는 400번대 응답은 OperatedException을 발생시켜 처리합니다. 요청 실패 사유를 기재해 클라이언트가 올바른 요청으로 재시도할 수 있도록 유도합니다. 반면 서버에서 예상치 못한 예외가 발생하는 등 에러 발생 원인이 서버에 있는 500에러는 전체 Exception 클래스로 발생합니다. 위에서 설명 드린 error handler의 동작 원리처럼 OperatedException은 가장 구체적으로 지정되어 있기 때문에 server_side_exception_handler가 아닌 operated_exception_handler을 통해 예외가 발생하게 됩니다.

이제 main.py에서 FastAPI()가 handler를 바라보도록 main.py에 set_error_handlers을 추가합니다.

# main.py
from fastapi import FastAPI 
from exception.handler import set_error_handlers

# ... 
app = FastAPI()
# ...
set_error_handlers(app)

여기까지 사용자 정의 예외 클래스와 exception handler 작성이 완료되었다면 handler 처리는 전역 수준에서 동작하도록 잘 처리된 것입니다.

3-1. router 레벨에서 exception handler는 구현 가능한가

그렇다면 실무 레벨에 exception handler를 적용하면서 드는 한 가지 궁금증이 있습니다. exception handler를 전역 말고 특정 router 레벨에서 선언할 수 있는가입니다.

먼저 router 레벨을 관리하는 APIRouter 인스턴스에는 exception_handler를 지원하지 않습니다. FastAPI에서 exception_handler라고 인식할 수 있는 데코레이터를 제공하지는 않지만 APIRoute를 통해 사용자 정의의 예외 처리를 router 레벨에서 수행할 수 있습니다.

상세한 내용은 APIRouter에서 exception_handler를 생성할 수 없는가에 대한 fast api 이슈APIRoute를 사용한 custom handler에 대한 내용이 작성된 공식 문서를 참고해보면 좋을 것 같습니다.


REFERENCES

profile
Java Spring 백엔드 개발자입니다. java 외에도 다양하고 흥미로운 언어와 프레임워크를 학습하는 것을 좋아합니다.

0개의 댓글