Browse Source

↩️ Revert " Add support for raising exceptions (including `HTTPException`) in dependencies with `yield` in the exit code, do not support them in background tasks (#10831)"

This reverts commit a4aa79e0b4.
pull/14099/head
Sebastián Ramírez 3 weeks ago
parent
commit
adf21d3f23
  1. 43
      docs/en/docs/tutorial/dependencies/dependencies-with-yield.md
  2. 30
      docs_src/dependencies/tutorial008b.py
  3. 31
      docs_src/dependencies/tutorial008b_an.py
  4. 32
      docs_src/dependencies/tutorial008b_an_py39.py
  5. 52
      fastapi/applications.py
  6. 1
      fastapi/concurrency.py
  7. 5
      fastapi/dependencies/utils.py
  8. 25
      fastapi/middleware/asyncexitstack.py
  9. 9
      pyproject.toml
  10. 20
      tests/test_dependency_contextmanager.py
  11. 39
      tests/test_tutorial/test_dependencies/test_tutorial008b.py

43
docs/en/docs/tutorial/dependencies/dependencies-with-yield.md

@ -1,8 +1,8 @@
# Dependencies with yield { #dependencies-with-yield } # Dependencies with yield { #dependencies-with-yield }
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>. FastAPI supports dependencies that do some <abbr title='sometimes also called "exit", "cleanup", "teardown", "close", "context managers", ...'>extra steps after finishing</abbr>.
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 /// 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. 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] *} {* ../../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] *} {* ../../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. 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 <a href="https://docs.python.org/3/library/context
You saw that you can use dependencies with `yield` and have `try` blocks that catch exceptions. You saw that you can use dependencies with `yield` and have `try` blocks that catch exceptions.
The same way, you could raise an `HTTPException` or similar in the exit code, after the `yield`. It might be tempting to raise an `HTTPException` or similar in the exit code, after the `yield`. But **it won't work**.
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`).
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 what allows anything set in the dependency (e.g. a DB session) to, for example, be used by background tasks.
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*.
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.
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.
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}.
/// tip /// tip
@ -142,12 +156,14 @@ participant dep as Dep with yield
participant operation as Path Operation participant operation as Path Operation
participant tasks as Background tasks participant tasks as Background tasks
Note over client,operation: Can raise exceptions, including HTTPException 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 client ->> dep: Start request
Note over dep: Run code up to yield Note over dep: Run code up to yield
opt raise Exception opt raise
dep -->> handler: Raise Exception dep -->> handler: Raise HTTPException
handler -->> client: HTTP error response handler -->> client: HTTP error response
dep -->> dep: Raise other exception
end end
dep ->> operation: Run dependency, e.g. DB session dep ->> operation: Run dependency, e.g. DB session
opt raise opt raise
@ -156,15 +172,20 @@ participant tasks as Background tasks
dep -->> dep: Can catch exception, raise a new HTTPException, raise other exception dep -->> dep: Can catch exception, raise a new HTTPException, raise other exception
end end
handler -->> client: HTTP error response handler -->> client: HTTP error response
operation -->> dep: Raise other exception
dep -->> handler: Auto forward exception
end end
operation ->> client: Return response to client operation ->> client: Return response to client
Note over client,operation: Response is already sent, can't change it anymore Note over client,operation: Response is already sent, can't change it anymore
opt Tasks opt Tasks
operation -->> tasks: Send background tasks operation -->> tasks: Send background tasks
end end
opt Raise other exception 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 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 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 } ### Using context managers in dependencies with `yield` { #using-context-managers-in-dependencies-with-yield }

30
docs_src/dependencies/tutorial008b.py

@ -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

31
docs_src/dependencies/tutorial008b_an.py

@ -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

32
docs_src/dependencies/tutorial008b_an_py39.py

@ -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

52
fastapi/applications.py

@ -22,6 +22,7 @@ from fastapi.exception_handlers import (
) )
from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError
from fastapi.logger import logger from fastapi.logger import logger
from fastapi.middleware.asyncexitstack import AsyncExitStackMiddleware
from fastapi.openapi.docs import ( from fastapi.openapi.docs import (
get_redoc_html, get_redoc_html,
get_swagger_ui_html, get_swagger_ui_html,
@ -36,6 +37,8 @@ from starlette.datastructures import State
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.middleware import Middleware from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware 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.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, Response from starlette.responses import HTMLResponse, JSONResponse, Response
from starlette.routing import BaseRoute from starlette.routing import BaseRoute
@ -963,6 +966,55 @@ class FastAPI(Starlette):
self.middleware_stack: Union[ASGIApp, None] = None self.middleware_stack: Union[ASGIApp, None] = None
self.setup() 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]: def openapi(self) -> Dict[str, Any]:
""" """
Generate the OpenAPI schema of the application. This is called by FastAPI Generate the OpenAPI schema of the application. This is called by FastAPI

1
fastapi/concurrency.py

@ -1,3 +1,4 @@
from contextlib import AsyncExitStack as AsyncExitStack # noqa
from contextlib import asynccontextmanager as asynccontextmanager from contextlib import asynccontextmanager as asynccontextmanager
from typing import AsyncGenerator, ContextManager, TypeVar from typing import AsyncGenerator, ContextManager, TypeVar

5
fastapi/dependencies/utils.py

@ -2,6 +2,8 @@ import inspect
from contextlib import AsyncExitStack, contextmanager from contextlib import AsyncExitStack, contextmanager
from copy import copy, deepcopy from copy import copy, deepcopy
from dataclasses import dataclass from dataclasses import dataclass
from contextlib import contextmanager
from copy import deepcopy
from typing import ( from typing import (
Any, Any,
Callable, Callable,
@ -48,6 +50,7 @@ from fastapi._compat import (
) )
from fastapi.background import BackgroundTasks from fastapi.background import BackgroundTasks
from fastapi.concurrency import ( from fastapi.concurrency import (
AsyncExitStack,
asynccontextmanager, asynccontextmanager,
contextmanager_in_threadpool, contextmanager_in_threadpool,
) )
@ -631,6 +634,8 @@ async def solve_dependencies(
if sub_dependant.use_cache and sub_dependant.cache_key in dependency_cache: if sub_dependant.use_cache and sub_dependant.cache_key in dependency_cache:
solved = dependency_cache[sub_dependant.cache_key] solved = dependency_cache[sub_dependant.cache_key]
elif is_gen_callable(call) or is_async_gen_callable(call): 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( solved = await solve_generator(
call=call, stack=async_exit_stack, sub_values=solved_result.values call=call, stack=async_exit_stack, sub_values=solved_result.values
) )

25
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

9
pyproject.toml

@ -154,12 +154,6 @@ module = "fastapi.tests.*"
ignore_missing_imports = true ignore_missing_imports = true
check_untyped_defs = 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] [tool.pytest.ini_options]
addopts = [ addopts = [
"--strict-config", "--strict-config",
@ -253,9 +247,6 @@ ignore = [
"docs_src/security/tutorial005_an_py39.py" = ["B904"] "docs_src/security/tutorial005_an_py39.py" = ["B904"]
"docs_src/security/tutorial005_py310.py" = ["B904"] "docs_src/security/tutorial005_py310.py" = ["B904"]
"docs_src/security/tutorial005_py39.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] [tool.ruff.lint.isort]

20
tests/test_dependency_contextmanager.py

@ -1,9 +1,7 @@
import json
from typing import Dict from typing import Dict
import pytest import pytest
from fastapi import BackgroundTasks, Depends, FastAPI from fastapi import BackgroundTasks, Depends, FastAPI
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
app = FastAPI() app = FastAPI()
@ -204,13 +202,6 @@ async def get_sync_context_b_bg(
return state 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) client = TestClient(app)
@ -285,13 +276,9 @@ def test_background_tasks():
assert data["context_b"] == "started b" assert data["context_b"] == "started b"
assert data["context_a"] == "started a" assert data["context_a"] == "started a"
assert data["bg"] == "not set" 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_b"] == "finished b with a: started a"
assert state["context_a"] == "finished 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(): def test_sync_raise_raises():
@ -397,7 +384,4 @@ def test_sync_background_tasks():
assert data["sync_bg"] == "not set" assert data["sync_bg"] == "not set"
assert state["context_b"] == "finished b with a: started a" assert state["context_b"] == "finished b with a: started a"
assert state["context_a"] == "finished a" assert state["context_a"] == "finished a"
assert ( assert state["sync_bg"] == "sync_bg set - b: started b - a: started a"
state["sync_bg"]
== "sync_bg set - b: finished b with a: started a - a: finished a"
)

39
tests/test_tutorial/test_dependencies/test_tutorial008b.py

@ -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"}
Loading…
Cancel
Save