https://fastapi.tiangolo.com/tutorial/handling-errors/
client에게 error에 관해서 응답을 해주어야 할 때가 있다. 이 때 client가 다시 error를 발생하지 못하도록 정확한 이유와 status code를 보내주어야 하는데, fastapi에서는 이를 어떻게 처리하는 지 알아보도록 하자.
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도 같이 보낸다.
재밌는 것은 HTTPException
을 raise
시킨다는 것인데, 이는 HTTPException
이 API에 관한 추가정보만 달린 하나의 python exception이라는 것이다. 따라서 return
이 아니라 raise
시켜야 한다. 또한 HTTPException
을 raise
한 순간부터는 아래의 나머지 코드들이 실행되지 않게된다.
먼저 성공할 때의 결과이다. curl localhost:8888/items/foo
로 요청을 보내보도록 하자.
{"item":"The Foo Wrestlers"}
다음은 실패할 때의 결과이다. curl localhost:8888/items/foos
로 요청을 보내보도록 하자.
{"detail":"Item not found"}
참고로 detail
은 str
뿐만아니라 dict
나 list
도 가능하다.
@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]}
headers
에 dict
형식으로 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"}
이전에 만들어놓은 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
이다.
위의 경우에 UnicornException
을 raise
시키면 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..."}
FastAPI는 기본적으로 default로 특정 exception이 발생하면 어떤 응답을 보낼지 결정한 exception handler가 있다.
만약, 해당 exception handler에서 응답한 내용말고 custom하게 응답을 보내고 싶다면 override할 수 있다.
가령, handler의 path parameter가 int
인데 str
로 요청하면 RequestValidationError
가 발생한다. 이 RequestValidationError
를 exception_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_handler
로 RequestValidationError
를 처리하면 다음의 응답이 전달된다.
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'}]
참고로, ValidationError
와 RequestValidationError
는 다른데, RequestValidationError
이 ValidationError
의 sub-class이다. 즉, RequestValidationError
가 ValidationError
를 상속했다는 것이다.
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}
다음과 같이 HTTPException
도 exception handler
를 통해서 처리가 가능하다. curl localhost:8888/items/3
로 요청시 다음과 같이 응답이 오게 된다.
Nope! I don't like 3.
참고로 FastAPI
의 HTTPException
는 Starlette
의 HTTPException
하위 class이다. 단지 한 가지 차이만이 존재하는데 FastAPI
의 HTTPException
는 detail
에 JSON
으로 변환할 수 있는 data를 받을 수 있다는 것이고 Starlette
의 HTTPException
는 오직 str
만 받는다는 것이다.
만약, FastAPI
내부에서 반환하는 HTTPException
까지도 exception handler로 처리하고 싶다면 Starlette
의 HTTPException
로 exception handler를 처리해야한다. FastAPI
의 HTTPException
는 Starlette
의 HTTPException
의 하위 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)
다음과 같이 처리하면 FastAPI
의 HTTPException
과 Starlette
의 HTTPException
둘 모두 처리할 수 있다.
사실 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들을 사용하였다.