오류 처리 (Handling Errors)
오류 처리 (Handling Errors)
API를 쓰는 클라이언트에게 오류를 알려줘야 하는 상황은 생각보다 많아요. 그 클라이언트는 브라우저의 프론트엔드일 수도, 다른 사람이 만든 코드일 수도, IoT 기기일 수도 있어요. 어떤 경우든 클라이언트에게 이렇게 말해줘야 할 때가 있죠.
- 이 작업을 하기엔 권한이 부족해요.
- 이 리소스에 접근할 수 없어요.
- 접근하려는 항목이 존재하지 않아요.
- 등등.
이런 경우에는 보통 400대의 HTTP 상태 코드(400부터 499까지)를 돌려줘요. 조금 전에 말한 200대(200부터 299까지)와 비슷한 위치예요. 200대는 "요청이 성공했다"는 뜻이고, 400대는 "클라이언트 쪽에서 오류가 발생했다"는 뜻이에요.
여러분이 늘 보는 그 "404 Not Found" 오류(그리고 그 농담들)를 기억하시죠?
HTTPException 사용하기
클라이언트에게 오류가 담긴 HTTP 응답을 돌려줄 때는 HTTPException을 사용해요.
HTTPException 임포트하기
Python 3.10+
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]}
코드에서 HTTPException 던지기
HTTPException은 API에 필요한 추가 데이터를 담은 보통의 파이썬 예외예요. 파이썬 예외다 보니 return이 아니라 raise로 던져요.
이 말은 또 이런 뜻이에요. path operation 함수 안에서 호출하는 유틸리티 함수 안에서 HTTPException을 던지면, 그 시점에 path operation 함수 의 나머지 코드가 실행되지 않고 요청이 바로 종료돼요.
값을 반환하는 대신 예외를 던질 때의 이점은 Dependencies와 Security에 관한 장에서 더 분명히 드러나요.
아래 예시에서는 클라이언트가 존재하지 않는 ID로 항목을 요청하면 404 상태 코드와 함께 예외를 던져요.
Python 3.10+
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]}
결과 응답
클라이언트가 http://example.com/items/foo(item_id가 "foo")를 요청하면 HTTP 상태 코드 200과 함께 다음 JSON 응답을 받아요.
{"item": "The Foo Wrestlers"}
하지만 클라이언트가 http://example.com/items/bar(존재하지 않는 item_id인 "bar")를 요청하면, HTTP 상태 코드 404("not found" 오류)와 함께 다음 JSON 응답을 받아요.
{"detail": "Item not found"}
팁
HTTPException을 던질 때detail파라미터에는str뿐 아니라 JSON으로 변환할 수 있는 어떤 값이든 전달할 수 있어요.dict나list같은 것도 가능하죠. FastAPI가 알아서 처리해서 JSON으로 변환해 줘요.
커스텀 헤더 추가하기
HTTP 오류에 커스텀 헤더를 추가하는 게 유용한 상황도 있어요. 예를 들어 특정 보안 관련 상황 같은 경우죠. 아마 여러분 코드에서 직접 쓸 일은 없을 거예요. 하지만 고급 시나리오에서 필요해질 수 있으니, 커스텀 헤더를 추가하는 방법을 알아둘게요.
Python 3.10+
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]}
커스텀 예외 핸들러 설치하기
Starlette의 예외 유틸리티를 사용해서 커스텀 예외 핸들러를 추가할 수 있어요. 여러분(또는 사용하는 라이브러리)이 raise할 수 있는 커스텀 예외 UnicornException이 있다고 해볼게요. 이 예외를 FastAPI로 전역에서 처리하고 싶다면, @app.exception_handler()로 커스텀 예외 핸들러를 추가하면 돼요.
Python 3.10+
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}
여기서 /unicorns/yolo를 요청하면 path operation 이 UnicornException을 raise해요. 하지만 이 예외는 unicorn_exception_handler가 처리해 줘요. 그래서 HTTP 상태 코드 418과 함께 다음 JSON 내용을 담은 깔끔한 오류를 받게 돼요.
{"message": "Oops! yolo did something. There goes a rainbow..."}
기술적 세부 사항
from starlette.requests import Request와from starlette.responses import JSONResponse를 사용할 수도 있어요. FastAPI가starlette.responses를fastapi.responses로 동일하게 제공하는 건 개발자(여러분)를 위한 편의일 뿐이에요. 대부분의 응답은 실제로 Starlette에서 바로 온 거예요.Request도 마찬가지고요.
기본 예외 핸들러 재정의하기
FastAPI에는 기본 예외 핸들러들이 있어요. 이 핸들러들은 여러분이 HTTPException을 raise할 때, 그리고 요청에 잘못된 데이터가 있을 때 기본 JSON 응답을 돌려주는 일을 담당해요. 이 예외 핸들러들을 여러분만의 것으로 재정의(override)할 수 있어요.
요청 검증 예외 재정의하기
요청에 잘못된 데이터가 포함되면 FastAPI는 내부적으로 RequestValidationError를 raise해요. 그리고 그에 대한 기본 예외 핸들러도 함께 포함돼 있어요. 이 핸들러를 재정의하려면 RequestValidationError를 임포트하고 @app.exception_handler(RequestValidationError)로 예외 핸들러를 장식하면 돼요. 그러면 예외 핸들러가 Request와 예외를 받아요.
Python 3.10+
from fastapi import FastAPI, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import PlainTextResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
app = FastAPI()
@app.exception_handler(StarletteHTTPException)
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: RequestValidationError):
message = "Validation errors:"
for error in exc.errors():
message += f"\n Field: {error['loc']}, Error: {error['msg']}"
return PlainTextResponse(message, 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}
이제 /items/foo로 가면 기본 JSON 오류 대신 다음 내용을 받아요.
{
"detail": [
{
"loc": ["path", "item_id"],
"msg": "value is not a valid integer",
"type": "type_error.integer"
}
]
}
대신 다음과 같은 텍스트 버전을 받게 돼요.
Validation errors:
Field: ('path', 'item_id'), Error: Input should be a valid integer, unable to parse string as an integer
HTTPException 오류 핸들러 재정의하기
같은 방식으로 HTTPException 핸들러도 재정의할 수 있어요. 예를 들어 이런 오류에 대해 JSON 대신 일반 텍스트(plain text) 응답을 돌려주고 싶을 수 있겠죠.
Python 3.10+
from fastapi import FastAPI, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import PlainTextResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
app = FastAPI()
@app.exception_handler(StarletteHTTPException)
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: RequestValidationError):
message = "Validation errors:"
for error in exc.errors():
message += f"\n Field: {error['loc']}, Error: {error['msg']}"
return PlainTextResponse(message, 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}
기술적 세부 사항
from starlette.responses import PlainTextResponse를 사용할 수도 있어요. FastAPI가starlette.responses를fastapi.responses로 동일하게 제공하는 건 개발자(여러분)를 위한 편의일 뿐이에요. 대부분의 응답은 실제로 Starlette에서 바로 온 거예요.
경고
RequestValidationError에는 검증 오류가 발생한 파일 이름과 줄 정보가 담겨 있어요. 원한다면 로그에서 이 정보를 보여줄 수 있도록요. 하지만 이걸 그냥 문자열로 변환해서 그대로 돌려주면 시스템에 대한 정보가 조금 새어 나갈 수 있어요. 그래서 여기 코드에서는 각 오류를 하나씩 꺼내서 보여주는 거예요.
RequestValidationError의 body 사용하기
RequestValidationError에는 잘못된 데이터와 함께 받은 body가 들어 있어요. 앱을 개발하는 동안 이 body를 로그로 남겨 디버깅하거나, 사용자에게 돌려주는 데 쓸 수 있어요.
Python 3.10+
from fastapi import FastAPI, Request
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=422,
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
이제 다음과 같이 잘못된 항목을 보내 보면,
{
"title": "towel",
"size": "XL"
}
받은 body를 포함해 데이터가 잘못됐다는 응답을 받아요.
{
"detail": [
{
"loc": ["body", "size"],
"msg": "value is not a valid integer",
"type": "type_error.integer"
}
],
"body": {"title": "towel", "size": "XL"}
}
FastAPI의 HTTPException vs Starlette의 HTTPException
FastAPI에는 자체 HTTPException이 있어요. 그리고 FastAPI의 HTTPException 오류 클래스는 Starlette의 HTTPException 오류 클래스를 상속받아요. 유일한 차이는 FastAPI의 HTTPException이 detail 필드에 JSON으로 변환 가능한 어떤 데이터든 받아들이는 반면, Starlette의 HTTPException은 문자열만 받는다는 점이에요.
그래서 코드에서는 평소처럼 FastAPI의 HTTPException을 계속 raise하면 돼요. 다만 예외 핸들러를 등록할 때는 Starlette의 HTTPException에 대해 등록해야 해요. 이렇게 해야 Starlette 내부 코드나 Starlette 확장·플러그인이 Starlette HTTPException을 던져도 여러분의 핸들러가 제대로 잡아서 처리할 수 있어요.
FastAPI 예외 핸들러 재사용하기
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}
이 예시에서는 아주 표현력 있는 메시지로 오류를 그냥 출력만 하고 있어요. 하지만 요지는 보이시죠? 예외를 사용한 다음, 기본 예외 핸들러를 그대로 재사용하면 되는 거예요.