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"}