Skip to content

Commit 1dd9def

Browse files
authored
fix: fastapi exception handlers not work (#1880)
1 parent 7cb24a5 commit 1dd9def

File tree

6 files changed

+110
-60
lines changed

6 files changed

+110
-60
lines changed

examples/fastapi/_tests.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,23 @@ async def test_user_list(self, client: AsyncClient) -> None: # nosec
9696
await self.user_list(client)
9797

9898

99+
@pytest.mark.anyio
100+
async def test_404(client: AsyncClient) -> None:
101+
response = await client.get("/404")
102+
assert response.status_code == 404, response.text
103+
data = response.json()
104+
assert isinstance(data["detail"], str)
105+
106+
107+
@pytest.mark.anyio
108+
async def test_422(client: AsyncClient) -> None:
109+
response = await client.get("/422")
110+
assert response.status_code == 422, response.text
111+
data = response.json()
112+
assert isinstance(data["detail"], list)
113+
assert isinstance(data["detail"][0], dict)
114+
115+
99116
class TestUserEast(UserTester):
100117
timezone = "Asia/Shanghai"
101118
delta_hours = 8
@@ -123,6 +140,23 @@ async def test_user_list(self, client_east: AsyncClient) -> None: # nosec
123140
assert item.model_dump()["created_at"].hour == created_at.hour
124141

125142

143+
@pytest.mark.anyio
144+
async def test_404_east(client_east: AsyncClient) -> None:
145+
response = await client_east.get("/404")
146+
assert response.status_code == 404, response.text
147+
data = response.json()
148+
assert isinstance(data["detail"], str)
149+
150+
151+
@pytest.mark.anyio
152+
async def test_422_east(client_east: AsyncClient) -> None:
153+
response = await client_east.get("/422")
154+
assert response.status_code == 422, response.text
155+
data = response.json()
156+
assert isinstance(data["detail"], list)
157+
assert isinstance(data["detail"][0], dict)
158+
159+
126160
def query_without_app(pk: int) -> int:
127161
async def runner() -> bool:
128162
async with register_orm():

examples/fastapi/config.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,4 @@
88
db_url=os.getenv("DB_URL", "sqlite://db.sqlite3"),
99
modules={"models": ["models"]},
1010
generate_schemas=True,
11-
add_exception_handlers=True,
1211
)

examples/fastapi/main.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from examples.fastapi.config import register_orm
1010
from tortoise import Tortoise, generate_config
11-
from tortoise.contrib.fastapi import RegisterTortoise
11+
from tortoise.contrib.fastapi import RegisterTortoise, tortoise_exception_handlers
1212

1313

1414
@asynccontextmanager
@@ -23,7 +23,6 @@ async def lifespan_test(app: FastAPI) -> AsyncGenerator[None, None]:
2323
app=app,
2424
config=config,
2525
generate_schemas=True,
26-
add_exception_handlers=True,
2726
_create_db=True,
2827
):
2928
# db connected
@@ -47,5 +46,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
4746
# db connections closed
4847

4948

50-
app = FastAPI(title="Tortoise ORM FastAPI example", lifespan=lifespan)
49+
app = FastAPI(
50+
title="Tortoise ORM FastAPI example",
51+
lifespan=lifespan,
52+
exception_handlers=tortoise_exception_handlers(),
53+
)
5154
app.include_router(users_router, prefix="")

examples/fastapi/main_custom_timezone.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
1414
app,
1515
use_tz=False,
1616
timezone="Asia/Shanghai",
17+
add_exception_handlers=True,
1718
):
1819
# db connected
1920
yield

examples/fastapi/routers.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,14 @@ async def delete_user(user_id: int):
3333
if not deleted_count:
3434
raise HTTPException(status_code=404, detail=f"User {user_id} not found")
3535
return Status(message=f"Deleted user {user_id}")
36+
37+
38+
@router.get("/404")
39+
async def get_404():
40+
await Users.get(id=0)
41+
42+
43+
@router.get("/422")
44+
async def get_422():
45+
obj = await Users.create(username="foo")
46+
await Users.create(username=obj.username)

tortoise/contrib/fastapi/__init__.py

Lines changed: 58 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -7,25 +7,36 @@
77
from types import ModuleType
88
from typing import TYPE_CHECKING
99

10-
from fastapi.responses import JSONResponse
11-
from pydantic import BaseModel # pylint: disable=E0611
12-
from starlette.routing import _DefaultLifespan
13-
1410
from tortoise import Tortoise, connections
1511
from tortoise.exceptions import DoesNotExist, IntegrityError
1612
from tortoise.log import logger
1713

1814
if TYPE_CHECKING:
1915
from fastapi import FastAPI, Request
2016

17+
2118
if sys.version_info >= (3, 11):
2219
from typing import Self
2320
else:
2421
from typing_extensions import Self
2522

2623

27-
class HTTPNotFoundError(BaseModel):
28-
detail: str
24+
def tortoise_exception_handlers() -> dict:
25+
from fastapi.responses import JSONResponse
26+
27+
async def doesnotexist_exception_handler(request: "Request", exc: DoesNotExist):
28+
return JSONResponse(status_code=404, content={"detail": str(exc)})
29+
30+
async def integrityerror_exception_handler(request: "Request", exc: IntegrityError):
31+
return JSONResponse(
32+
status_code=422,
33+
content={"detail": [{"loc": [], "msg": str(exc), "type": "IntegrityError"}]},
34+
)
35+
36+
return {
37+
DoesNotExist: doesnotexist_exception_handler,
38+
IntegrityError: integrityerror_exception_handler,
39+
}
2940

3041

3142
class RegisterTortoise(AbstractAsyncContextManager):
@@ -122,17 +133,22 @@ def __init__(
122133
self._create_db = _create_db
123134

124135
if add_exception_handlers and app is not None:
136+
from starlette.middleware.exceptions import ExceptionMiddleware
137+
138+
warnings.warn(
139+
"Setting `add_exception_handlers` to be true is deprecated, "
140+
"use `FastAPI(exception_handlers=tortoise_exception_handlers())` instead."
141+
"See more about it on https://tortoise.github.io/examples/fastapi",
142+
DeprecationWarning,
143+
)
144+
original_call_func = ExceptionMiddleware.__call__
125145

126-
@app.exception_handler(DoesNotExist)
127-
async def doesnotexist_exception_handler(request: "Request", exc: DoesNotExist):
128-
return JSONResponse(status_code=404, content={"detail": str(exc)})
146+
async def wrap_middleware_call(self, *args, **kw) -> None:
147+
if DoesNotExist not in self._exception_handlers:
148+
self._exception_handlers.update(tortoise_exception_handlers())
149+
await original_call_func(self, *args, **kw)
129150

130-
@app.exception_handler(IntegrityError)
131-
async def integrityerror_exception_handler(request: "Request", exc: IntegrityError):
132-
return JSONResponse(
133-
status_code=422,
134-
content={"detail": [{"loc": [], "msg": str(exc), "type": "IntegrityError"}]},
135-
)
151+
ExceptionMiddleware.__call__ = wrap_middleware_call # type:ignore
136152

137153
async def init_orm(self) -> None: # pylint: disable=W0612
138154
await Tortoise.init(
@@ -166,8 +182,7 @@ async def __aexit__(self, *args, **kw) -> None:
166182

167183
def __await__(self) -> Generator[None, None, Self]:
168184
async def _self() -> Self:
169-
await self.init_orm()
170-
return self
185+
return await self.__aenter__()
171186

172187
return _self().__await__()
173188

@@ -182,8 +197,9 @@ def register_tortoise(
182197
add_exception_handlers: bool = False,
183198
) -> None:
184199
"""
185-
Registers ``startup`` and ``shutdown`` events to set-up and tear-down Tortoise-ORM
186-
inside a FastAPI application.
200+
Registers Tortoise-ORM with set-up at the beginning of FastAPI application's lifespan
201+
(which allow user to read/write data from/to db inside the lifespan function),
202+
and tear-down at the end of that lifespan.
187203
188204
You can configure using only one of ``config``, ``config_file``
189205
and ``(db_url, modules)``.
@@ -245,40 +261,26 @@ def register_tortoise(
245261
ConfigurationError
246262
For any configuration error
247263
"""
248-
orm = RegisterTortoise(
249-
app,
250-
config,
251-
config_file,
252-
db_url,
253-
modules,
254-
generate_schemas,
255-
add_exception_handlers,
256-
)
257-
if isinstance(lifespan := app.router.lifespan_context, _DefaultLifespan):
258-
# Leave on_event here to compare with old versions
259-
# So people can upgrade tortoise-orm in running project without changing any code
260-
261-
@app.on_event("startup")
262-
async def init_orm() -> None: # pylint: disable=W0612
263-
await orm.init_orm()
264-
265-
@app.on_event("shutdown")
266-
async def close_orm() -> None: # pylint: disable=W0612
267-
await orm.close_orm()
268-
269-
else:
270-
# If custom lifespan was passed to app, register tortoise in it
271-
warnings.warn(
272-
"`register_tortoise` function is deprecated, "
273-
"use the `RegisterTortoise` class instead."
274-
"See more about it on https://tortoise.github.io/examples/fastapi",
275-
DeprecationWarning,
276-
)
277-
278-
@asynccontextmanager
279-
async def orm_lifespan(app_instance: "FastAPI"):
280-
async with orm:
281-
async with lifespan(app_instance):
282-
yield
283-
284-
app.router.lifespan_context = orm_lifespan
264+
from fastapi.routing import _merge_lifespan_context
265+
266+
# Leave this function here to compare with old versions
267+
# So people can upgrade tortoise-orm in running project without changing any code
268+
269+
@asynccontextmanager
270+
async def orm_lifespan(app_instance: "FastAPI"):
271+
async with RegisterTortoise(
272+
app_instance,
273+
config,
274+
config_file,
275+
db_url,
276+
modules,
277+
generate_schemas,
278+
):
279+
yield
280+
281+
original_lifespan = app.router.lifespan_context
282+
app.router.lifespan_context = _merge_lifespan_context(orm_lifespan, original_lifespan)
283+
284+
if add_exception_handlers:
285+
for exp_type, endpoint in tortoise_exception_handlers().items():
286+
app.exception_handler(exp_type)(endpoint)

0 commit comments

Comments
 (0)