From adf21d3f2325735d37e79dc984e5d30e665f07a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Wed, 3 Sep 2025 17:45:41 +0200 Subject: [PATCH] =?UTF-8?q?=E2=86=A9=EF=B8=8F=20Revert=20"=E2=9C=A8=20Add?= =?UTF-8?q?=20support=20for=20raising=20exceptions=20(including=20`HTTPExc?= =?UTF-8?q?eption`)=20in=20dependencies=20with=20`yield`=20in=20the=20exit?= =?UTF-8?q?=20code,=20do=20not=20support=20them=20in=20background=20tasks?= =?UTF-8?q?=20(#10831)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit a4aa79e0b4cacc6b428d415d04d234a8c77af9d5. --- .../dependencies/dependencies-with-yield.md | 43 +++++++++++---- docs_src/dependencies/tutorial008b.py | 30 ----------- docs_src/dependencies/tutorial008b_an.py | 31 ----------- docs_src/dependencies/tutorial008b_an_py39.py | 32 ------------ fastapi/applications.py | 52 +++++++++++++++++++ fastapi/concurrency.py | 1 + fastapi/dependencies/utils.py | 5 ++ fastapi/middleware/asyncexitstack.py | 25 +++++++++ pyproject.toml | 9 ---- tests/test_dependency_contextmanager.py | 20 +------ .../test_dependencies/test_tutorial008b.py | 39 -------------- 11 files changed, 117 insertions(+), 170 deletions(-) delete mode 100644 docs_src/dependencies/tutorial008b.py delete mode 100644 docs_src/dependencies/tutorial008b_an.py delete mode 100644 docs_src/dependencies/tutorial008b_an_py39.py create mode 100644 fastapi/middleware/asyncexitstack.py delete mode 100644 tests/test_tutorial/test_dependencies/test_tutorial008b.py diff --git a/docs/en/docs/tutorial/dependencies/dependencies-with-yield.md b/docs/en/docs/tutorial/dependencies/dependencies-with-yield.md index 2e2a6a8e3..8a94186f4 100644 --- a/docs/en/docs/tutorial/dependencies/dependencies-with-yield.md +++ b/docs/en/docs/tutorial/dependencies/dependencies-with-yield.md @@ -1,8 +1,8 @@ # Dependencies with yield { #dependencies-with-yield } -FastAPI supports dependencies that do some extra steps after finishing. +FastAPI supports dependencies that do some extra steps after finishing. -To do this, use `yield` instead of `return`, and write the extra steps (code) after. +To do this, use `yield` instead of `return`, and write the extra steps after. /// tip @@ -27,7 +27,7 @@ In fact, FastAPI uses those two decorators internally. For example, you could use this to create a database session and close it after finishing. -Only the code prior to and including the `yield` statement is executed before creating a response: +Only the code prior to and including the `yield` statement is executed before sending a response: {* ../../docs_src/dependencies/tutorial007.py hl[2:4] *} @@ -77,7 +77,7 @@ And, in turn, `dependency_b` needs the value from `dependency_a` (here named `de {* ../../docs_src/dependencies/tutorial008_an_py39.py hl[18:19,26:27] *} -The same way, you could have some dependencies with `yield` and some other dependencies with `return`, and have some of those depend on some of the others. +The same way, you could have dependencies with `yield` and `return` mixed. And you could have a single dependency that requires several other dependencies with `yield`, etc. @@ -97,7 +97,21 @@ This works thanks to Python's > dep: Start request Note over dep: Run code up to yield - opt raise Exception - dep -->> handler: Raise Exception + opt raise + dep -->> handler: Raise HTTPException handler -->> client: HTTP error response + dep -->> dep: Raise other exception end dep ->> operation: Run dependency, e.g. DB session opt raise @@ -156,15 +172,20 @@ participant tasks as Background tasks dep -->> dep: Can catch exception, raise a new HTTPException, raise other exception end handler -->> client: HTTP error response + operation -->> dep: Raise other exception + dep -->> handler: Auto forward exception end - operation ->> client: Return response to client Note over client,operation: Response is already sent, can't change it anymore opt Tasks operation -->> tasks: Send background tasks end opt Raise other exception - tasks -->> tasks: Handle exceptions in the background task code + tasks -->> dep: Raise other exception + end + Note over dep: After yield + opt Handle other exception + dep -->> dep: Handle exception, can't change response. E.g. close DB session. end ``` @@ -238,7 +259,7 @@ Underneath, the `open("./somefile.txt")` creates an object that is called a "Con When the `with` block finishes, it makes sure to close the file, even if there were exceptions. -When you create a dependency with `yield`, **FastAPI** will internally create a context manager for it, and combine it with some other related tools. +When you create a dependency with `yield`, **FastAPI** will internally convert it to a context manager, and combine it with some other related tools. ### Using context managers in dependencies with `yield` { #using-context-managers-in-dependencies-with-yield } diff --git a/docs_src/dependencies/tutorial008b.py b/docs_src/dependencies/tutorial008b.py deleted file mode 100644 index 163e96600..000000000 --- a/docs_src/dependencies/tutorial008b.py +++ /dev/null @@ -1,30 +0,0 @@ -from fastapi import Depends, FastAPI, HTTPException - -app = FastAPI() - - -data = { - "plumbus": {"description": "Freshly pickled plumbus", "owner": "Morty"}, - "portal-gun": {"description": "Gun to create portals", "owner": "Rick"}, -} - - -class OwnerError(Exception): - pass - - -def get_username(): - try: - yield "Rick" - except OwnerError as e: - raise HTTPException(status_code=400, detail=f"Owner error: {e}") - - -@app.get("/items/{item_id}") -def get_item(item_id: str, username: str = Depends(get_username)): - if item_id not in data: - raise HTTPException(status_code=404, detail="Item not found") - item = data[item_id] - if item["owner"] != username: - raise OwnerError(username) - return item diff --git a/docs_src/dependencies/tutorial008b_an.py b/docs_src/dependencies/tutorial008b_an.py deleted file mode 100644 index 84d8f12c1..000000000 --- a/docs_src/dependencies/tutorial008b_an.py +++ /dev/null @@ -1,31 +0,0 @@ -from fastapi import Depends, FastAPI, HTTPException -from typing_extensions import Annotated - -app = FastAPI() - - -data = { - "plumbus": {"description": "Freshly pickled plumbus", "owner": "Morty"}, - "portal-gun": {"description": "Gun to create portals", "owner": "Rick"}, -} - - -class OwnerError(Exception): - pass - - -def get_username(): - try: - yield "Rick" - except OwnerError as e: - raise HTTPException(status_code=400, detail=f"Owner error: {e}") - - -@app.get("/items/{item_id}") -def get_item(item_id: str, username: Annotated[str, Depends(get_username)]): - if item_id not in data: - raise HTTPException(status_code=404, detail="Item not found") - item = data[item_id] - if item["owner"] != username: - raise OwnerError(username) - return item diff --git a/docs_src/dependencies/tutorial008b_an_py39.py b/docs_src/dependencies/tutorial008b_an_py39.py deleted file mode 100644 index 3b8434c81..000000000 --- a/docs_src/dependencies/tutorial008b_an_py39.py +++ /dev/null @@ -1,32 +0,0 @@ -from typing import Annotated - -from fastapi import Depends, FastAPI, HTTPException - -app = FastAPI() - - -data = { - "plumbus": {"description": "Freshly pickled plumbus", "owner": "Morty"}, - "portal-gun": {"description": "Gun to create portals", "owner": "Rick"}, -} - - -class OwnerError(Exception): - pass - - -def get_username(): - try: - yield "Rick" - except OwnerError as e: - raise HTTPException(status_code=400, detail=f"Owner error: {e}") - - -@app.get("/items/{item_id}") -def get_item(item_id: str, username: Annotated[str, Depends(get_username)]): - if item_id not in data: - raise HTTPException(status_code=404, detail="Item not found") - item = data[item_id] - if item["owner"] != username: - raise OwnerError(username) - return item diff --git a/fastapi/applications.py b/fastapi/applications.py index 05c7bd2be..c5a8f1228 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -22,6 +22,7 @@ from fastapi.exception_handlers import ( ) from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError from fastapi.logger import logger +from fastapi.middleware.asyncexitstack import AsyncExitStackMiddleware from fastapi.openapi.docs import ( get_redoc_html, get_swagger_ui_html, @@ -36,6 +37,8 @@ from starlette.datastructures import State from starlette.exceptions import HTTPException from starlette.middleware import Middleware from starlette.middleware.base import BaseHTTPMiddleware +from starlette.middleware.errors import ServerErrorMiddleware +from starlette.middleware.exceptions import ExceptionMiddleware from starlette.requests import Request from starlette.responses import HTMLResponse, JSONResponse, Response from starlette.routing import BaseRoute @@ -963,6 +966,55 @@ class FastAPI(Starlette): self.middleware_stack: Union[ASGIApp, None] = None self.setup() + def build_middleware_stack(self) -> ASGIApp: + # Duplicate/override from Starlette to add AsyncExitStackMiddleware + # inside of ExceptionMiddleware, inside of custom user middlewares + debug = self.debug + error_handler = None + exception_handlers = {} + + for key, value in self.exception_handlers.items(): + if key in (500, Exception): + error_handler = value + else: + exception_handlers[key] = value + + middleware = ( + [Middleware(ServerErrorMiddleware, handler=error_handler, debug=debug)] + + self.user_middleware + + [ + Middleware( + ExceptionMiddleware, handlers=exception_handlers, debug=debug + ), + # Add FastAPI-specific AsyncExitStackMiddleware for dependencies with + # contextvars. + # This needs to happen after user middlewares because those create a + # new contextvars context copy by using a new AnyIO task group. + # The initial part of dependencies with 'yield' is executed in the + # FastAPI code, inside all the middlewares. However, the teardown part + # (after 'yield') is executed in the AsyncExitStack in this middleware. + # If the AsyncExitStack lived outside of the custom middlewares and + # contextvars were set in a dependency with 'yield' in that internal + # contextvars context, the values would not be available in the + # outer context of the AsyncExitStack. + # By placing the middleware and the AsyncExitStack here, inside all + # user middlewares, the code before and after 'yield' in dependencies + # with 'yield' is executed in the same contextvars context. Thus, all values + # set in contextvars before 'yield' are still available after 'yield,' as + # expected. + # Additionally, by having this AsyncExitStack here, after the + # ExceptionMiddleware, dependencies can now catch handled exceptions, + # e.g. HTTPException, to customize the teardown code (e.g. DB session + # rollback). + Middleware(AsyncExitStackMiddleware), + ] + ) + + app = self.router + for cls, options in reversed(middleware): + app = cls(app=app, **options) + return app + def openapi(self) -> Dict[str, Any]: """ Generate the OpenAPI schema of the application. This is called by FastAPI diff --git a/fastapi/concurrency.py b/fastapi/concurrency.py index 3202c7078..686eebcea 100644 --- a/fastapi/concurrency.py +++ b/fastapi/concurrency.py @@ -1,3 +1,4 @@ +from contextlib import AsyncExitStack as AsyncExitStack # noqa from contextlib import asynccontextmanager as asynccontextmanager from typing import AsyncGenerator, ContextManager, TypeVar diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 081b63a8b..ce3cf7da1 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -2,6 +2,8 @@ import inspect from contextlib import AsyncExitStack, contextmanager from copy import copy, deepcopy from dataclasses import dataclass +from contextlib import contextmanager +from copy import deepcopy from typing import ( Any, Callable, @@ -48,6 +50,7 @@ from fastapi._compat import ( ) from fastapi.background import BackgroundTasks from fastapi.concurrency import ( + AsyncExitStack, asynccontextmanager, contextmanager_in_threadpool, ) @@ -631,6 +634,8 @@ async def solve_dependencies( if sub_dependant.use_cache and sub_dependant.cache_key in dependency_cache: solved = dependency_cache[sub_dependant.cache_key] elif is_gen_callable(call) or is_async_gen_callable(call): + stack = request.scope.get("fastapi_astack") + assert isinstance(stack, AsyncExitStack) solved = await solve_generator( call=call, stack=async_exit_stack, sub_values=solved_result.values ) diff --git a/fastapi/middleware/asyncexitstack.py b/fastapi/middleware/asyncexitstack.py new file mode 100644 index 000000000..30a0ae626 --- /dev/null +++ b/fastapi/middleware/asyncexitstack.py @@ -0,0 +1,25 @@ +from typing import Optional + +from fastapi.concurrency import AsyncExitStack +from starlette.types import ASGIApp, Receive, Scope, Send + + +class AsyncExitStackMiddleware: + def __init__(self, app: ASGIApp, context_name: str = "fastapi_astack") -> None: + self.app = app + self.context_name = context_name + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + dependency_exception: Optional[Exception] = None + async with AsyncExitStack() as stack: + scope[self.context_name] = stack + try: + await self.app(scope, receive, send) + except Exception as e: + dependency_exception = e + raise e + if dependency_exception: + # This exception was possibly handled by the dependency but it should + # still bubble up so that the ServerErrorMiddleware can return a 500 + # or the ExceptionMiddleware can catch and handle any other exceptions + raise dependency_exception diff --git a/pyproject.toml b/pyproject.toml index 7709451ff..f97559acb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -154,12 +154,6 @@ module = "fastapi.tests.*" ignore_missing_imports = true check_untyped_defs = true -[[tool.mypy.overrides]] -module = "docs_src.*" -disallow_incomplete_defs = false -disallow_untyped_defs = false -disallow_untyped_calls = false - [tool.pytest.ini_options] addopts = [ "--strict-config", @@ -253,9 +247,6 @@ ignore = [ "docs_src/security/tutorial005_an_py39.py" = ["B904"] "docs_src/security/tutorial005_py310.py" = ["B904"] "docs_src/security/tutorial005_py39.py" = ["B904"] -"docs_src/dependencies/tutorial008b.py" = ["B904"] -"docs_src/dependencies/tutorial008b_an.py" = ["B904"] -"docs_src/dependencies/tutorial008b_an_py39.py" = ["B904"] [tool.ruff.lint.isort] diff --git a/tests/test_dependency_contextmanager.py b/tests/test_dependency_contextmanager.py index 039c423b9..92cdaef7a 100644 --- a/tests/test_dependency_contextmanager.py +++ b/tests/test_dependency_contextmanager.py @@ -1,9 +1,7 @@ -import json from typing import Dict import pytest from fastapi import BackgroundTasks, Depends, FastAPI -from fastapi.responses import StreamingResponse from fastapi.testclient import TestClient app = FastAPI() @@ -204,13 +202,6 @@ async def get_sync_context_b_bg( return state -@app.middleware("http") -async def middleware(request, call_next): - response: StreamingResponse = await call_next(request) - response.headers["x-state"] = json.dumps(state.copy()) - return response - - client = TestClient(app) @@ -285,13 +276,9 @@ def test_background_tasks(): assert data["context_b"] == "started b" assert data["context_a"] == "started a" assert data["bg"] == "not set" - middleware_state = json.loads(response.headers["x-state"]) - assert middleware_state["context_b"] == "finished b with a: started a" - assert middleware_state["context_a"] == "finished a" - assert middleware_state["bg"] == "not set" assert state["context_b"] == "finished b with a: started a" assert state["context_a"] == "finished a" - assert state["bg"] == "bg set - b: finished b with a: started a - a: finished a" + assert state["bg"] == "bg set - b: started b - a: started a" def test_sync_raise_raises(): @@ -397,7 +384,4 @@ def test_sync_background_tasks(): assert data["sync_bg"] == "not set" assert state["context_b"] == "finished b with a: started a" assert state["context_a"] == "finished a" - assert ( - state["sync_bg"] - == "sync_bg set - b: finished b with a: started a - a: finished a" - ) + assert state["sync_bg"] == "sync_bg set - b: started b - a: started a" diff --git a/tests/test_tutorial/test_dependencies/test_tutorial008b.py b/tests/test_tutorial/test_dependencies/test_tutorial008b.py deleted file mode 100644 index 4d7092265..000000000 --- a/tests/test_tutorial/test_dependencies/test_tutorial008b.py +++ /dev/null @@ -1,39 +0,0 @@ -import importlib - -import pytest -from fastapi.testclient import TestClient - -from ...utils import needs_py39 - - -@pytest.fixture( - name="client", - params=[ - "tutorial008b", - "tutorial008b_an", - pytest.param("tutorial008b_an_py39", marks=needs_py39), - ], -) -def get_client(request: pytest.FixtureRequest): - mod = importlib.import_module(f"docs_src.dependencies.{request.param}") - - client = TestClient(mod.app) - return client - - -def test_get_no_item(client: TestClient): - response = client.get("/items/foo") - assert response.status_code == 404, response.text - assert response.json() == {"detail": "Item not found"} - - -def test_owner_error(client: TestClient): - response = client.get("/items/plumbus") - assert response.status_code == 400, response.text - assert response.json() == {"detail": "Owner error: Rick"} - - -def test_get_item(client: TestClient): - response = client.get("/items/portal-gun") - assert response.status_code == 200, response.text - assert response.json() == {"description": "Gun to create portals", "owner": "Rick"}