]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
✨ Add support for raising exceptions (including `HTTPException`) in dependencies...
authorSebastián Ramírez <tiangolo@gmail.com>
Mon, 25 Dec 2023 17:57:35 +0000 (18:57 +0100)
committerGitHub <noreply@github.com>
Mon, 25 Dec 2023 17:57:35 +0000 (18:57 +0100)
* ♻️ Refactor dependency AsyncExitStack logic, exit dependencies after creating the response, before sending it

* ✅ Update tests for dependencies exit, check they are finished before the response is sent

* 🔥 Remove ExitAsyncStackMiddleware as it's no longer needed

* 📝 Update docs for dependencies with yield

* 📝 Update release notes

* 📝 Add source examples for new dependencies with yield raising

* ✅ Add tests for new dependencies raising after yield

* 📝 Update release notes

15 files changed:
docs/en/docs/release-notes.md
docs/en/docs/tutorial/dependencies/dependencies-with-yield.md
docs_src/dependencies/tutorial008b.py [new file with mode: 0644]
docs_src/dependencies/tutorial008b_an.py [new file with mode: 0644]
docs_src/dependencies/tutorial008b_an_py39.py [new file with mode: 0644]
fastapi/applications.py
fastapi/concurrency.py
fastapi/dependencies/utils.py
fastapi/middleware/asyncexitstack.py [deleted file]
fastapi/routing.py
pyproject.toml
tests/test_dependency_contextmanager.py
tests/test_tutorial/test_dependencies/test_tutorial008b.py [new file with mode: 0644]
tests/test_tutorial/test_dependencies/test_tutorial008b_an.py [new file with mode: 0644]
tests/test_tutorial/test_dependencies/test_tutorial008b_an_py39.py [new file with mode: 0644]

index b8b6f9ae947ee2cf6cd8cbe423da777e27dc5f56..12bc12d260e326ad8a4c94b82e88c8984d61e11b 100644 (file)
@@ -7,6 +7,108 @@ hide:
 
 ## Latest Changes
 
+### Dependencies with `yield`, `HTTPException` and Background Tasks
+
+Dependencies with `yield` now can raise `HTTPException` and other exceptions after `yield`. 🎉
+
+Read the new docs here: [Dependencies with `yield` and `HTTPException`](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield/#dependencies-with-yield-and-httpexception).
+
+```Python
+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"Onwer 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
+```
+
+---
+
+Before FastAPI 0.106.0, raising exceptions after `yield` was not possible, the exit code in dependencies with `yield` was executed *after* the response was sent, so [Exception Handlers](../handling-errors.md#install-custom-exception-handlers){.internal-link target=_blank} would have already run.
+
+This was designed this way mainly to allow using the same objects "yielded" by dependencies inside of background tasks, because the exit code would be executed after the background tasks were finished.
+
+Nevertheless, as this would mean waiting for the response to travel through the network while unnecessarily holding a resource in a dependency with yield (for example a database connection), this was changed in FastAPI 0.106.0.
+
+Additionally, a background task is normally an independent set of logic that should be handled separately, with its own resources (e.g. its own database connection).
+
+If you used to rely on this behavior, now you should create the resources for background tasks inside the background task itself, and use internally only data that doesn't depend on the resources of dependencies with `yield`.
+
+For example, instead of using the same database session, you would create a new database session inside of the background task, and you would obtain the objects from the database using this new session. And then instead of passing the object from the database as a parameter to the background task function, you would pass the ID of that object and then obtain the object again inside the background task function.
+
+The sequence of execution before FastAPI 0.106.0 was like this diagram:
+
+Time flows from top to bottom. And each column is one of the parts interacting or executing code.
+
+```mermaid
+sequenceDiagram
+
+participant client as Client
+participant handler as Exception handler
+participant dep as Dep with yield
+participant operation as Path Operation
+participant tasks as Background tasks
+
+    Note over client,tasks: Can raise exception for dependency, handled after response is sent
+    Note over client,operation: Can raise HTTPException and can change the response
+    client ->> dep: Start request
+    Note over dep: Run code up to yield
+    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
+        operation -->> dep: Raise HTTPException
+        dep -->> handler: Auto forward exception
+        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 -->> 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
+```
+
+The new execution flow can be found in the docs: [Execution of dependencies with `yield`](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield/#execution-of-dependencies-with-yield).
+
+### Internal
+
 * 👥 Update FastAPI People. PR [#10567](https://github.com/tiangolo/fastapi/pull/10567) by [@tiangolo](https://github.com/tiangolo).
 
 ## 0.105.0
index fe18f1f1d9afe92bae75f2cc6e93d5d12983dcac..4ead4682cba9aa930476acee50d2c77bc396e08e 100644 (file)
@@ -1,8 +1,8 @@
 # Dependencies with yield
 
-FastAPI supports dependencies that do some <abbr title='sometimes also called "exit", "cleanup", "teardown", "close", "context managers", ...'>extra steps after finishing</abbr>.
+FastAPI supports dependencies that do some <abbr title='sometimes also called "exit code", "cleanup code", "teardown code", "closing code", "context manager exit code", etc.'>extra steps after finishing</abbr>.
 
-To do this, use `yield` instead of `return`, and write the extra steps after.
+To do this, use `yield` instead of `return`, and write the extra steps (code) after.
 
 !!! tip
     Make sure to use `yield` one single time.
@@ -21,7 +21,7 @@ To do this, use `yield` instead of `return`, and write the extra steps after.
 
 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 sending a response:
+Only the code prior to and including the `yield` statement is executed before creating a response:
 
 ```Python hl_lines="2-4"
 {!../../../docs_src/dependencies/tutorial007.py!}
@@ -40,7 +40,7 @@ The code following the `yield` statement is executed after the response has been
 ```
 
 !!! tip
-    You can use `async` or normal functions.
+    You can use `async` or regular functions.
 
     **FastAPI** will do the right thing with each, the same as with normal dependencies.
 
@@ -114,7 +114,7 @@ And, in turn, `dependency_b` needs the value from `dependency_a` (here named `de
     {!> ../../../docs_src/dependencies/tutorial008.py!}
     ```
 
-The same way, you could have dependencies with `yield` and `return` mixed.
+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.
 
 And you could have a single dependency that requires several other dependencies with `yield`, etc.
 
@@ -131,24 +131,38 @@ You can have any combinations of dependencies that you want.
 
 You saw that you can use dependencies with `yield` and have `try` blocks that catch exceptions.
 
-It might be tempting to raise an `HTTPException` or similar in the exit code, after the `yield`. But **it won't work**.
+The same way, you could raise an `HTTPException` or similar in the exit code, after the `yield`.
 
-The exit code in dependencies with `yield` is executed *after* the response is sent, so [Exception Handlers](../handling-errors.md#install-custom-exception-handlers){.internal-link target=_blank} will have already run. There's nothing catching exceptions thrown by your dependencies in the exit code (after the `yield`).
+!!! tip
 
-So, if you raise an `HTTPException` after the `yield`, the default (or any custom) exception handler that catches `HTTPException`s and returns an HTTP 400 response won't be there to catch that exception anymore.
+    This is a somewhat advanced technique, and in most of the cases you won't really need it, as you can raise exceptions (including `HTTPException`) from inside of the rest of your application code, for example, in the *path operation function*.
 
-This is what allows anything set in the dependency (e.g. a DB session) to, for example, be used by background tasks.
+    But it's there for you if you need it. 🤓
 
-Background tasks are run *after* the response has been sent. So there's no way to raise an `HTTPException` because there's not even a way to change the response that is *already sent*.
+=== "Python 3.9+"
 
-But if a background task creates a DB error, at least you can rollback or cleanly close the session in the dependency with `yield`, and maybe log the error or report it to a remote tracking system.
+    ```Python hl_lines="18-22  31"
+    {!> ../../../docs_src/dependencies/tutorial008b_an_py39.py!}
+    ```
 
-If you have some code that you know could raise an exception, do the most normal/"Pythonic" thing and add a `try` block in that section of the code.
+=== "Python 3.8+"
 
-If you have custom exceptions that you would like to handle *before* returning the response and possibly modifying the response, maybe even raising an `HTTPException`, create a [Custom Exception Handler](../handling-errors.md#install-custom-exception-handlers){.internal-link target=_blank}.
+    ```Python hl_lines="17-21  30"
+    {!> ../../../docs_src/dependencies/tutorial008b_an.py!}
+    ```
 
-!!! tip
-    You can still raise exceptions including `HTTPException` *before* the `yield`. But not after.
+=== "Python 3.8+ non-Annotated"
+
+    !!! tip
+        Prefer to use the `Annotated` version if possible.
+
+    ```Python hl_lines="16-20  29"
+    {!> ../../../docs_src/dependencies/tutorial008b.py!}
+    ```
+
+An alternative you could use to catch exceptions (and possibly also raise another `HTTPException`) is ot create a [Custom Exception Handler](../handling-errors.md#install-custom-exception-handlers){.internal-link target=_blank}.
+
+## Execution of dependencies with `yield`
 
 The sequence of execution is more or less like this diagram. Time flows from top to bottom. And each column is one of the parts interacting or executing code.
 
@@ -161,34 +175,30 @@ participant dep as Dep with yield
 participant operation as Path Operation
 participant tasks as Background tasks
 
-    Note over client,tasks: Can raise exception for dependency, handled after response is sent
-    Note over client,operation: Can raise HTTPException and can change the response
+    Note over client,operation: Can raise exceptions, including HTTPException
     client ->> dep: Start request
     Note over dep: Run code up to yield
-    opt raise
-        dep -->> handler: Raise HTTPException
+    opt raise Exception
+        dep -->> handler: Raise Exception
         handler -->> client: HTTP error response
-        dep -->> dep: Raise other exception
     end
     dep ->> operation: Run dependency, e.g. DB session
     opt raise
-        operation -->> dep: Raise HTTPException
-        dep -->> handler: Auto forward exception
+        operation -->> dep: Raise Exception (e.g. HTTPException)
+        opt handle
+            dep -->> dep: Can catch exception, raise a new HTTPException, raise other exception
+            dep -->> handler: Auto forward 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 -->> 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.
+        tasks -->> tasks: Handle exceptions in the background task code
     end
 ```
 
@@ -198,10 +208,33 @@ participant tasks as Background tasks
     After one of those responses is sent, no other response can be sent.
 
 !!! tip
-    This diagram shows `HTTPException`, but you could also raise any other exception for which you create a [Custom Exception Handler](../handling-errors.md#install-custom-exception-handlers){.internal-link target=_blank}.
+    This diagram shows `HTTPException`, but you could also raise any other exception that you catch in a dependency with `yield` or with a [Custom Exception Handler](../handling-errors.md#install-custom-exception-handlers){.internal-link target=_blank}.
 
     If you raise any exception, it will be passed to the dependencies with yield, including `HTTPException`, and then **again** to the exception handlers. If there's no exception handler for that exception, it will then be handled by the default internal `ServerErrorMiddleware`, returning a 500 HTTP status code, to let the client know that there was an error in the server.
 
+## Dependencies with `yield`, `HTTPException` and Background Tasks
+
+!!! warning
+    You most probably don't need these technical details, you can skip this section and continue below.
+
+    These details are useful mainly if you were using a version of FastAPI prior to 0.106.0 and used resources from dependencies with `yield` in background tasks.
+
+Before FastAPI 0.106.0, raising exceptions after `yield` was not possible, the exit code in dependencies with `yield` was executed *after* the response was sent, so [Exception Handlers](../handling-errors.md#install-custom-exception-handlers){.internal-link target=_blank} would have already run.
+
+This was designed this way mainly to allow using the same objects "yielded" by dependencies inside of background tasks, because the exit code would be executed after the background tasks were finished.
+
+Nevertheless, as this would mean waiting for the response to travel through the network while unnecessarily holding a resource in a dependency with yield (for example a database connection), this was changed in FastAPI 0.106.0.
+
+!!! tip
+
+    Additionally, a background task is normally an independent set of logic that should be handled separately, with its own resources (e.g. its own database connection).
+
+    So, this way you will probably have cleaner code.
+
+If you used to rely on this behavior, now you should create the resources for background tasks inside the background task itself, and use internally only data that doesn't depend on the resources of dependencies with `yield`.
+
+For example, instead of using the same database session, you would create a new database session inside of the background task, and you would obtain the objects from the database using this new session. And then instead of passing the object from the database as a parameter to the background task function, you would pass the ID of that object and then obtain the object again inside the background task function.
+
 ## Context Managers
 
 ### What are "Context Managers"
@@ -220,7 +253,7 @@ Underneath, the `open("./somefile.txt")` creates an object that is a called a "C
 
 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 convert it to a context manager, and combine it with some other related tools.
+When you create a dependency with `yield`, **FastAPI** will internally create a context manager for it, and combine it with some other related tools.
 
 ### Using context managers in dependencies with `yield`
 
diff --git a/docs_src/dependencies/tutorial008b.py b/docs_src/dependencies/tutorial008b.py
new file mode 100644 (file)
index 0000000..4a1a70d
--- /dev/null
@@ -0,0 +1,30 @@
+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"Onwer 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
new file mode 100644 (file)
index 0000000..3a0f139
--- /dev/null
@@ -0,0 +1,31 @@
+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"Onwer 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
new file mode 100644 (file)
index 0000000..30c9cdc
--- /dev/null
@@ -0,0 +1,32 @@
+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"Onwer 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
index 3021d75937d1cebba69d73d685e8975350f93c64..597c60a56788f7507bb60bc8527f507521d451c4 100644 (file)
@@ -22,7 +22,6 @@ 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,
@@ -37,8 +36,6 @@ 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
@@ -966,55 +963,6 @@ 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
index 754061c862dadbdfd0c57a563b76fbd0fb5497a4..894bd3ed11873b1eecb2a5c0b85ad575b6f7de71 100644 (file)
@@ -1,4 +1,3 @@
-from contextlib import AsyncExitStack as AsyncExitStack  # noqa
 from contextlib import asynccontextmanager as asynccontextmanager
 from typing import AsyncGenerator, ContextManager, TypeVar
 
index 4e88410a5ec1fb525283f62064ed461364526a63..b73473484159c0d3e43c2c8ab4e8d45507887a48 100644 (file)
@@ -1,5 +1,5 @@
 import inspect
-from contextlib import contextmanager
+from contextlib import AsyncExitStack, contextmanager
 from copy import deepcopy
 from typing import (
     Any,
@@ -46,7 +46,6 @@ from fastapi._compat import (
 )
 from fastapi.background import BackgroundTasks
 from fastapi.concurrency import (
-    AsyncExitStack,
     asynccontextmanager,
     contextmanager_in_threadpool,
 )
@@ -529,6 +528,7 @@ async def solve_dependencies(
     response: Optional[Response] = None,
     dependency_overrides_provider: Optional[Any] = None,
     dependency_cache: Optional[Dict[Tuple[Callable[..., Any], Tuple[str]], Any]] = None,
+    async_exit_stack: AsyncExitStack,
 ) -> Tuple[
     Dict[str, Any],
     List[Any],
@@ -575,6 +575,7 @@ async def solve_dependencies(
             response=response,
             dependency_overrides_provider=dependency_overrides_provider,
             dependency_cache=dependency_cache,
+            async_exit_stack=async_exit_stack,
         )
         (
             sub_values,
@@ -590,10 +591,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=stack, sub_values=sub_values
+                call=call, stack=async_exit_stack, sub_values=sub_values
             )
         elif is_coroutine_callable(call):
             solved = await call(**sub_values)
diff --git a/fastapi/middleware/asyncexitstack.py b/fastapi/middleware/asyncexitstack.py
deleted file mode 100644 (file)
index 30a0ae6..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-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
index 54d53bbbfb81222c01b4cd9463d187c6b60b4e21..589ecca2aaf73f09d922002c0dc941e49c04b95b 100644 (file)
@@ -216,95 +216,124 @@ def get_request_handler(
         actual_response_class = response_class
 
     async def app(request: Request) -> Response:
-        try:
-            body: Any = None
-            if body_field:
-                if is_body_form:
-                    body = await request.form()
-                    stack = request.scope.get("fastapi_astack")
-                    assert isinstance(stack, AsyncExitStack)
-                    stack.push_async_callback(body.close)
+        exception_to_reraise: Optional[Exception] = None
+        response: Union[Response, None] = None
+        async with AsyncExitStack() as async_exit_stack:
+            # TODO: remove this scope later, after a few releases
+            # This scope fastapi_astack is no longer used by FastAPI, kept for
+            # compatibility, just in case
+            request.scope["fastapi_astack"] = async_exit_stack
+            try:
+                body: Any = None
+                if body_field:
+                    if is_body_form:
+                        body = await request.form()
+                        async_exit_stack.push_async_callback(body.close)
+                    else:
+                        body_bytes = await request.body()
+                        if body_bytes:
+                            json_body: Any = Undefined
+                            content_type_value = request.headers.get("content-type")
+                            if not content_type_value:
+                                json_body = await request.json()
+                            else:
+                                message = email.message.Message()
+                                message["content-type"] = content_type_value
+                                if message.get_content_maintype() == "application":
+                                    subtype = message.get_content_subtype()
+                                    if subtype == "json" or subtype.endswith("+json"):
+                                        json_body = await request.json()
+                            if json_body != Undefined:
+                                body = json_body
+                            else:
+                                body = body_bytes
+            except json.JSONDecodeError as e:
+                validation_error = RequestValidationError(
+                    [
+                        {
+                            "type": "json_invalid",
+                            "loc": ("body", e.pos),
+                            "msg": "JSON decode error",
+                            "input": {},
+                            "ctx": {"error": e.msg},
+                        }
+                    ],
+                    body=e.doc,
+                )
+                exception_to_reraise = validation_error
+                raise validation_error from e
+            except HTTPException as e:
+                exception_to_reraise = e
+                raise
+            except Exception as e:
+                http_error = HTTPException(
+                    status_code=400, detail="There was an error parsing the body"
+                )
+                exception_to_reraise = http_error
+                raise http_error from e
+            try:
+                solved_result = await solve_dependencies(
+                    request=request,
+                    dependant=dependant,
+                    body=body,
+                    dependency_overrides_provider=dependency_overrides_provider,
+                    async_exit_stack=async_exit_stack,
+                )
+                values, errors, background_tasks, sub_response, _ = solved_result
+            except Exception as e:
+                exception_to_reraise = e
+                raise e
+            if errors:
+                validation_error = RequestValidationError(
+                    _normalize_errors(errors), body=body
+                )
+                exception_to_reraise = validation_error
+                raise validation_error
+            else:
+                try:
+                    raw_response = await run_endpoint_function(
+                        dependant=dependant, values=values, is_coroutine=is_coroutine
+                    )
+                except Exception as e:
+                    exception_to_reraise = e
+                    raise e
+                if isinstance(raw_response, Response):
+                    if raw_response.background is None:
+                        raw_response.background = background_tasks
+                    response = raw_response
                 else:
-                    body_bytes = await request.body()
-                    if body_bytes:
-                        json_body: Any = Undefined
-                        content_type_value = request.headers.get("content-type")
-                        if not content_type_value:
-                            json_body = await request.json()
-                        else:
-                            message = email.message.Message()
-                            message["content-type"] = content_type_value
-                            if message.get_content_maintype() == "application":
-                                subtype = message.get_content_subtype()
-                                if subtype == "json" or subtype.endswith("+json"):
-                                    json_body = await request.json()
-                        if json_body != Undefined:
-                            body = json_body
-                        else:
-                            body = body_bytes
-        except json.JSONDecodeError as e:
-            raise RequestValidationError(
-                [
-                    {
-                        "type": "json_invalid",
-                        "loc": ("body", e.pos),
-                        "msg": "JSON decode error",
-                        "input": {},
-                        "ctx": {"error": e.msg},
-                    }
-                ],
-                body=e.doc,
-            ) from e
-        except HTTPException:
-            raise
-        except Exception as e:
-            raise HTTPException(
-                status_code=400, detail="There was an error parsing the body"
-            ) from e
-        solved_result = await solve_dependencies(
-            request=request,
-            dependant=dependant,
-            body=body,
-            dependency_overrides_provider=dependency_overrides_provider,
-        )
-        values, errors, background_tasks, sub_response, _ = solved_result
-        if errors:
-            raise RequestValidationError(_normalize_errors(errors), body=body)
-        else:
-            raw_response = await run_endpoint_function(
-                dependant=dependant, values=values, is_coroutine=is_coroutine
-            )
-
-            if isinstance(raw_response, Response):
-                if raw_response.background is None:
-                    raw_response.background = background_tasks
-                return raw_response
-            response_args: Dict[str, Any] = {"background": background_tasks}
-            # If status_code was set, use it, otherwise use the default from the
-            # response class, in the case of redirect it's 307
-            current_status_code = (
-                status_code if status_code else sub_response.status_code
-            )
-            if current_status_code is not None:
-                response_args["status_code"] = current_status_code
-            if sub_response.status_code:
-                response_args["status_code"] = sub_response.status_code
-            content = await serialize_response(
-                field=response_field,
-                response_content=raw_response,
-                include=response_model_include,
-                exclude=response_model_exclude,
-                by_alias=response_model_by_alias,
-                exclude_unset=response_model_exclude_unset,
-                exclude_defaults=response_model_exclude_defaults,
-                exclude_none=response_model_exclude_none,
-                is_coroutine=is_coroutine,
-            )
-            response = actual_response_class(content, **response_args)
-            if not is_body_allowed_for_status_code(response.status_code):
-                response.body = b""
-            response.headers.raw.extend(sub_response.headers.raw)
-            return response
+                    response_args: Dict[str, Any] = {"background": background_tasks}
+                    # If status_code was set, use it, otherwise use the default from the
+                    # response class, in the case of redirect it's 307
+                    current_status_code = (
+                        status_code if status_code else sub_response.status_code
+                    )
+                    if current_status_code is not None:
+                        response_args["status_code"] = current_status_code
+                    if sub_response.status_code:
+                        response_args["status_code"] = sub_response.status_code
+                    content = await serialize_response(
+                        field=response_field,
+                        response_content=raw_response,
+                        include=response_model_include,
+                        exclude=response_model_exclude,
+                        by_alias=response_model_by_alias,
+                        exclude_unset=response_model_exclude_unset,
+                        exclude_defaults=response_model_exclude_defaults,
+                        exclude_none=response_model_exclude_none,
+                        is_coroutine=is_coroutine,
+                    )
+                    response = actual_response_class(content, **response_args)
+                    if not is_body_allowed_for_status_code(response.status_code):
+                        response.body = b""
+                    response.headers.raw.extend(sub_response.headers.raw)
+        # 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
+        if exception_to_reraise:
+            raise exception_to_reraise
+        assert response is not None, "An error occurred while generating the request"
+        return response
 
     return app
 
@@ -313,16 +342,22 @@ def get_websocket_app(
     dependant: Dependant, dependency_overrides_provider: Optional[Any] = None
 ) -> Callable[[WebSocket], Coroutine[Any, Any, Any]]:
     async def app(websocket: WebSocket) -> None:
-        solved_result = await solve_dependencies(
-            request=websocket,
-            dependant=dependant,
-            dependency_overrides_provider=dependency_overrides_provider,
-        )
-        values, errors, _, _2, _3 = solved_result
-        if errors:
-            raise WebSocketRequestValidationError(_normalize_errors(errors))
-        assert dependant.call is not None, "dependant.call must be a function"
-        await dependant.call(**values)
+        async with AsyncExitStack() as async_exit_stack:
+            # TODO: remove this scope later, after a few releases
+            # This scope fastapi_astack is no longer used by FastAPI, kept for
+            # compatibility, just in case
+            websocket.scope["fastapi_astack"] = async_exit_stack
+            solved_result = await solve_dependencies(
+                request=websocket,
+                dependant=dependant,
+                dependency_overrides_provider=dependency_overrides_provider,
+                async_exit_stack=async_exit_stack,
+            )
+            values, errors, _, _2, _3 = solved_result
+            if errors:
+                raise WebSocketRequestValidationError(_normalize_errors(errors))
+            assert dependant.call is not None, "dependant.call must be a function"
+            await dependant.call(**values)
 
     return app
 
index e67486ae31bf8c126a370dc2ea4baaa636f6bad8..fa072e59523da723e9870a6b25ae8e4b974d5001 100644 (file)
@@ -84,6 +84,12 @@ 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",
@@ -167,6 +173,9 @@ 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.isort]
index 03ef56c4d7e5bfedf4f4cdf94f18f3ec8b95af40..b07f9aa5b6c6b3236206a4f4e1c805c38750c962 100644 (file)
@@ -1,7 +1,9 @@
+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()
@@ -200,6 +202,13 @@ 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)
 
 
@@ -274,9 +283,13 @@ 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: started b - a: started a"
+    assert state["bg"] == "bg set - b: finished b with a: started a - a: finished a"
 
 
 def test_sync_raise_raises():
@@ -382,4 +395,7 @@ 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: started b - a: started a"
+    assert (
+        state["sync_bg"]
+        == "sync_bg set - b: finished b with a: started a - a: finished a"
+    )
diff --git a/tests/test_tutorial/test_dependencies/test_tutorial008b.py b/tests/test_tutorial/test_dependencies/test_tutorial008b.py
new file mode 100644 (file)
index 0000000..ed4f4aa
--- /dev/null
@@ -0,0 +1,23 @@
+from fastapi.testclient import TestClient
+
+from docs_src.dependencies.tutorial008b import app
+
+client = TestClient(app)
+
+
+def test_get_no_item():
+    response = client.get("/items/foo")
+    assert response.status_code == 404, response.text
+    assert response.json() == {"detail": "Item not found"}
+
+
+def test_owner_error():
+    response = client.get("/items/plumbus")
+    assert response.status_code == 400, response.text
+    assert response.json() == {"detail": "Onwer error: Rick"}
+
+
+def test_get_item():
+    response = client.get("/items/portal-gun")
+    assert response.status_code == 200, response.text
+    assert response.json() == {"description": "Gun to create portals", "owner": "Rick"}
diff --git a/tests/test_tutorial/test_dependencies/test_tutorial008b_an.py b/tests/test_tutorial/test_dependencies/test_tutorial008b_an.py
new file mode 100644 (file)
index 0000000..aa76ad6
--- /dev/null
@@ -0,0 +1,23 @@
+from fastapi.testclient import TestClient
+
+from docs_src.dependencies.tutorial008b_an import app
+
+client = TestClient(app)
+
+
+def test_get_no_item():
+    response = client.get("/items/foo")
+    assert response.status_code == 404, response.text
+    assert response.json() == {"detail": "Item not found"}
+
+
+def test_owner_error():
+    response = client.get("/items/plumbus")
+    assert response.status_code == 400, response.text
+    assert response.json() == {"detail": "Onwer error: Rick"}
+
+
+def test_get_item():
+    response = client.get("/items/portal-gun")
+    assert response.status_code == 200, response.text
+    assert response.json() == {"description": "Gun to create portals", "owner": "Rick"}
diff --git a/tests/test_tutorial/test_dependencies/test_tutorial008b_an_py39.py b/tests/test_tutorial/test_dependencies/test_tutorial008b_an_py39.py
new file mode 100644 (file)
index 0000000..aa76ad6
--- /dev/null
@@ -0,0 +1,23 @@
+from fastapi.testclient import TestClient
+
+from docs_src.dependencies.tutorial008b_an import app
+
+client = TestClient(app)
+
+
+def test_get_no_item():
+    response = client.get("/items/foo")
+    assert response.status_code == 404, response.text
+    assert response.json() == {"detail": "Item not found"}
+
+
+def test_owner_error():
+    response = client.get("/items/plumbus")
+    assert response.status_code == 400, response.text
+    assert response.json() == {"detail": "Onwer error: Rick"}
+
+
+def test_get_item():
+    response = client.get("/items/portal-gun")
+    assert response.status_code == 200, response.text
+    assert response.json() == {"description": "Gun to create portals", "owner": "Rick"}