FastAPI를 배워보자 10일차 - Error Handling

0

fastapi

목록 보기
10/13

Handling Errors

https://fastapi.tiangolo.com/tutorial/handling-errors/

client에게 error에 관해서 응답을 해주어야 할 때가 있다. 이 때 client가 다시 error를 발생하지 못하도록 정확한 이유와 status code를 보내주어야 하는데, fastapi에서는 이를 어떻게 처리하는 지 알아보도록 하자.

HTTPException

HTTP 응답을 error와 함께 client에게 전달하기 위해서는 HTTPException을 사용하는 것이 가장 좋다.

from fastapi import FastAPI, HTTPException

app = FastAPI()

items = {"foo": "The Foo Wrestlers"}

@app.get("/items/{item_id}")
async def read_item(item_id: str):
    if item_id not in items:
        raise HTTPException(status_code=404, detail="Item not found")
    return {"item": items[item_id]}

다음의 code는 client에게서 item_id를 받아내고 items에 없으면 HTTPException을 발생시키고 있으면 반환해주는 코드이다. 만약 없다면 404 Not Found status code와 함께 Item not found라는 msg도 같이 보낸다.

재밌는 것은 HTTPExceptionraise시킨다는 것인데, 이는 HTTPException이 API에 관한 추가정보만 달린 하나의 python exception이라는 것이다. 따라서 return이 아니라 raise시켜야 한다. 또한 HTTPExceptionraise한 순간부터는 아래의 나머지 코드들이 실행되지 않게된다.

먼저 성공할 때의 결과이다. curl localhost:8888/items/foo로 요청을 보내보도록 하자.

{"item":"The Foo Wrestlers"}

다음은 실패할 때의 결과이다. curl localhost:8888/items/foos로 요청을 보내보도록 하자.

{"detail":"Item not found"}

참고로 detailstr뿐만아니라 dictlist도 가능하다.

@app.get("/items/{item_id}")
async def read_item(item_id: str):
    if item_id not in items:
        raise HTTPException(status_code=404, detail={"data": "empty"})
    return {"item": items[item_id]}

다음과 같이 detail{"data": "empty"}로 바꾸어도 정상적으로 동작한다.

curl localhost:8888/items/foos로 요청을 보내보도록 하자.

{"detail":{"data":"empty"}}

보안을 위해서 header에 error에 관한 custom header를 추가할 수 있다. 개발자가 따로 header를 직접 달아줄 필요없이 HTTPException를 통해서 header를 추가해줄 수가 있다.

from fastapi import FastAPI, HTTPException

app = FastAPI()

items = {"foo": "The Foo Wrestlers"}

@app.get("/items-header/{item_id}")
async def read_item_header(item_id: str):
    if item_id not in items:
        raise HTTPException(
            status_code=404,
            detail="Item not found",
            headers={"X-Error": "There goes my error"},
        )
    return {"item": items[item_id]}

headersdict형식으로 custome header를 넣어주기만하면 된다.

응답을 살펴보면 x-error로 header가 잘 설정된 것을 볼 수 있다.

curl localhost:8888/items-header/foos -i
HTTP/1.1 404 Not Found
date: Wed, 17 Jan 2024 08:41:12 GMT
server: uvicorn
x-error: There goes my error
content-length: 27
content-type: application/json

{"detail":"Item not found"}

Custom exception handlers

이전에 만들어놓은 Exception들이 있어서 이를 활용해보고 싶다면 @app.exception_handler를 사용하면 된다.

@app.exception_handler는 특정 Exception이 발생하면 해당 Exception에 따라 어떻게 응답할 것인지를 정의한 것이다.

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

class UnicornException(Exception):
    def __init__(self, name: str):
        self.name = name

app = FastAPI()

@app.exception_handler(UnicornException)
async def unicorn_exception_handler(request: Request, exc: UnicornException):
    return JSONResponse(
        status_code=418,
        content={"message": f"Oops! {exc.name} did something. There goes a rainbow..."},
    )

@app.get("/unicorns/{name}")
async def read_unicorn(name: str):
    if name == "yolo":
        raise UnicornException(name=name)
    return {"unicorn_name": name}

UnicornException이라는 custom exception을 raise했을 때, @app.exception_handelr(UnicornException) 데코레이터를 연결한 unicorn_exception_handler가 이를 처리한다. 이때 parameter는 Request와 처리할 Exception이다.

위의 경우에 UnicornExceptionraise시키면 JSONResponse로 응답을 처리하는 것을 볼 수있다.

curl localhost:8888/unicorns/yolo -i

HTTP/1.1 418 I'm a Teapot
date: Wed, 17 Jan 2024 09:05:54 GMT
server: uvicorn
content-length: 63
content-type: application/json

{"message":"Oops! yolo did something. There goes a rainbow..."}

Default exception handler 오버라이딩

FastAPI는 기본적으로 default로 특정 exception이 발생하면 어떤 응답을 보낼지 결정한 exception handler가 있다.

만약, 해당 exception handler에서 응답한 내용말고 custom하게 응답을 보내고 싶다면 override할 수 있다.

가령, handler의 path parameter가 int인데 str로 요청하면 RequestValidationError가 발생한다. 이 RequestValidationErrorexception_handler로 처리하도록 하자.

from fastapi import FastAPI, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import PlainTextResponse

app = FastAPI()

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
    return PlainTextResponse("The Exception is " + str(exc), status_code=400)

@app.get("/items/{item_id}")
async def read_item(item_id: int):
    if item_id == 3:
        raise HTTPException(status_code=418, detail="Nope! I don't like 3.")
    return {"item_id": item_id}

위의 코드는 RequestValidationError이 발생하면 The Exception is 와 함께 exception을 string으로 변환한 결과를 보여준다.

기존에는 RequestValidationError이 발생할 경우 다음의 응답이 전달된다.

{"detail":[{"type":"int_parsing","loc":["path","item_id"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"foo","url":"https://errors.pydantic.dev/2.4/v/int_parsing"}]}

반면, 우리가 exception_handlerRequestValidationError를 처리하면 다음의 응답이 전달된다.

The Exception is [{'type': 'int_parsing', 'loc': ('path', 'item_id'), 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'input': 'foo', 'url': 'https://errors.pydantic.dev/2.4/v/int_parsing'}]

참고로, ValidationErrorRequestValidationError는 다른데, RequestValidationErrorValidationError의 sub-class이다. 즉, RequestValidationErrorValidationError를 상속했다는 것이다.

RequestValidationError는 client의 request에 대한 검증만 하기때문에 client의 문제라면 별 문제없이 잘못된 요청에 대한 error response를 만들어준다. ValidationError는 개발자가 response_model에 잘못된 pydantic 응답 모델을 넣을 경우도 처리한다. 이 경우에는 server에 에러 로그를 남기고 client에게는 Internal Server Error와 HTTP status code 500만 반환한다. 이는 client에게 internal한 정보를 넘겨주지 않도록하여 보안 취약점을 보완하기 위한 것이다.

HTTPException또한 exception handler를 통해서 오버라이딩할 수 있다. 만약 HTTPException이 발생하면 json으로 응답을 주기 보다는 plain text로 응답을 주고 싶을 때도 있다. 이 경우 다음과 같이 error handler를 쓸 수 있다.

from fastapi import FastAPI, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import PlainTextResponse

app = FastAPI()

@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc):
    return PlainTextResponse(str(exc.detail), status_code=exc.status_code)

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
    return PlainTextResponse(str(exc), status_code=400)

@app.get("/items/{item_id}")
async def read_item(item_id: int):
    if item_id == 3:
        raise HTTPException(status_code=418, detail="Nope! I don't like 3.")
    return {"item_id": item_id}

다음과 같이 HTTPExceptionexception handler를 통해서 처리가 가능하다. curl localhost:8888/items/3로 요청시 다음과 같이 응답이 오게 된다.

Nope! I don't like 3.

참고로 FastAPIHTTPExceptionStarletteHTTPException 하위 class이다. 단지 한 가지 차이만이 존재하는데 FastAPIHTTPExceptiondetailJSON으로 변환할 수 있는 data를 받을 수 있다는 것이고 StarletteHTTPException는 오직 str만 받는다는 것이다.

만약, FastAPI 내부에서 반환하는 HTTPException까지도 exception handler로 처리하고 싶다면 StarletteHTTPException로 exception handler를 처리해야한다. FastAPIHTTPExceptionStarletteHTTPException의 하위 class이기 때문에 문제없이 검출되며, FastAPI내부의 HTTPException 발생도 캐치할 수 있다.

from starlette.exceptions import HTTPException as StarletteHTTPException

@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request, exc):
    return PlainTextResponse(str(exc.detail), status_code=exc.status_code)

다음과 같이 처리하면 FastAPIHTTPExceptionStarletteHTTPException 둘 모두 처리할 수 있다.

사실 default exception handler를 오버라이딩해야할 일은 많지않다. 그리고 그닥 권장하지도 않는다. 왜냐하면 Exception의 상속 구조에 따라 원치않은 Exception도 함께 처리될 수 있기 때문이다. 가령 A라는 Exception이 B라는 Exception을 상속하고 있다면 B라는 Exception에 대한 exception handler 처리 시에 A Exception도 같이 처리되기 때문이다.

단, 다음과 같이 추가정보를 입력하고 싶을 때에는 유용하게 쓰일 수 있다. 가령 RequestValidationError는 exception반환 시 client가 어떤 body를 보냈는 지 알려주지 않는다. 디버깅을 위해서 body부분을 추가할 수 있다.

from fastapi import FastAPI, Request, status
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel

app = FastAPI()

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    return JSONResponse(
        status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
        content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}),
    )

class Item(BaseModel):
    title: str
    size: int

@app.post("/items/")
async def create_item(item: Item):
    return item

위의 code에서 content부분에 "body"를 추가하였다. 이제 결과를 확인해보도록 하자.

curl -X POST localhost:8888/items/ -H "Content-Type: application/json" -d '{"title": "hello", "si
ze": "XL}'

다음의 응답이 오게 된다.

{"detail":[{"type":"json_invalid","loc":["body",27],"msg":"JSON decode error","input":{},"ctx":{"error":"Unterminated string starting at"}}],"body":"{\"title\": \"hello\", \"size\": \"XL}"}

exception handler를 사용하는 또 다른 이유는 logging의 목적도 있다. 어디서 어떻게 exception이 발생했고, 반환되었는지 알기위해서 하나의 middleware처럼 쓰는 것이다. 이를 위해서는 기존의 exception_handler의 동작은 그대로 두도록 해야하므로 FastAPI의 exception handler들을 재사용해야한다. fastapi.exception_handlers에서 각 exception handler들을 찾을 수 있다.

from fastapi import FastAPI, HTTPException
from fastapi.exception_handlers import (
    http_exception_handler,
    request_validation_exception_handler,
)
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException

app = FastAPI()

@app.exception_handler(StarletteHTTPException)
async def custom_http_exception_handler(request, exc):
    print(f"OMG! An HTTP error!: {repr(exc)}")
    return await http_exception_handler(request, exc)


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
    print(f"OMG! The client sent invalid data!: {exc}")
    return await request_validation_exception_handler(request, exc)


@app.get("/items/{item_id}")
async def read_item(item_id: int):
    if item_id == 3:
        raise HTTPException(status_code=418, detail="Nope! I don't like 3.")
    return {"item_id": item_id}

다음과 같이 exception handler마다 logging을 추가하고 기존의 exception handler 동작을 위해서 fastapi.exception_handelrs 모듈의 handler들을 사용하였다.

0개의 댓글

관련 채용 정보