From 8ffd22f890e450c616c8e6c4ab26af29833da1d9 Mon Sep 17 00:00:00 2001 From: Jeremy Epstein Date: Tue, 28 Jan 2025 13:55:51 +1100 Subject: [PATCH 1/9] Fix: make solve_dependencies re-raise RuntimeError If an async generator dependency raises RuntimeError: generator didn't yield, make solve_dependencies catch and re-raise it, to more easily identify the dependency responsible for the error, and to provide more information on how to fix the dependency --- fastapi/dependencies/utils.py | 21 ++++++++++++++++-- tests/test_dependency_runtime_errors.py | 29 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 tests/test_dependency_runtime_errors.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 4b69e39a1..a927dbb69 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -54,7 +54,7 @@ from fastapi.concurrency import ( contextmanager_in_threadpool, ) from fastapi.dependencies.models import Dependant, SecurityRequirement -from fastapi.exceptions import DependencyScopeError +from fastapi.exceptions import DependencyScopeError, FastAPIError from fastapi.logger import logger from fastapi.security.base import SecurityBase from fastapi.security.oauth2 import OAuth2, SecurityScopes @@ -549,7 +549,24 @@ async def _solve_generator( cm = contextmanager_in_threadpool(contextmanager(dependant.call)(**sub_values)) elif dependant.is_async_gen_callable: cm = asynccontextmanager(dependant.call)(**sub_values) - return await stack.enter_async_context(cm) + + try: + solved = await stack.enter_async_context(cm) + except RuntimeError as ex: + if str(ex) != "generator didn't yield": + raise ex + + dependency_name = getattr(call, "__name__", "(unknown)") + raise FastAPIError( + f"Dependency {dependency_name} raised: {ex}. There's a high chance that " + "this is a dependency with yield that has a block with a bare except, or a " + "block with except Exception, and is not raising the exception again. Read " + "more about it in the docs: " + "https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield" + "/#dependencies-with-yield-and-except" + ) from ex + + return solved @dataclass diff --git a/tests/test_dependency_runtime_errors.py b/tests/test_dependency_runtime_errors.py new file mode 100644 index 000000000..5dd45976e --- /dev/null +++ b/tests/test_dependency_runtime_errors.py @@ -0,0 +1,29 @@ +import pytest +from anyio import open_file +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() + + +async def get_username(): + try: + async with await open_file("/path/to/sanchez.txt", "r") as f: + yield await f.read() # pragma: no cover + except OSError as ex: + raise RuntimeError("File something something, wubba lubba dub dub!") from ex + + +@app.get("/me") +def get_me(username: str = Depends(get_username)): + return username # pragma: no cover + + +client = TestClient(app) + + +@pytest.mark.anyio +def test_runtime_error(): + with pytest.raises(RuntimeError) as exc_info: + client.get("/me") + assert "File something something" in exc_info.value.args[0] From e29fb2c08d148353cd0781c20a2af205b53bca63 Mon Sep 17 00:00:00 2001 From: Jeremy Epstein Date: Tue, 11 Nov 2025 10:43:17 +1100 Subject: [PATCH 2/9] Tweak wording of dependency with yield exception message Co-authored-by: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> --- fastapi/dependencies/utils.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index a927dbb69..668d46cba 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -559,9 +559,8 @@ async def _solve_generator( dependency_name = getattr(call, "__name__", "(unknown)") raise FastAPIError( f"Dependency {dependency_name} raised: {ex}. There's a high chance that " - "this is a dependency with yield that has a block with a bare except, or a " - "block with except Exception, and is not raising the exception again. Read " - "more about it in the docs: " + "this is a dependency with yield that catches an exception using except, " + "but doesn't raise the exception again. Read more about it in the docs: " "https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield" "/#dependencies-with-yield-and-except" ) from ex From ba5867afb21172be287f4fe6658def4d60bdbd4b Mon Sep 17 00:00:00 2001 From: Jeremy Epstein Date: Tue, 11 Nov 2025 10:46:22 +1100 Subject: [PATCH 3/9] call should be dependant.call --- fastapi/dependencies/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 668d46cba..d6062f2f6 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -556,7 +556,7 @@ async def _solve_generator( if str(ex) != "generator didn't yield": raise ex - dependency_name = getattr(call, "__name__", "(unknown)") + dependency_name = getattr(dependant.call, "__name__", "(unknown)") raise FastAPIError( f"Dependency {dependency_name} raised: {ex}. There's a high chance that " "this is a dependency with yield that catches an exception using except, " From c5cb6df3155a20b06834d706f78188d649537218 Mon Sep 17 00:00:00 2001 From: Jeremy Epstein Date: Tue, 11 Nov 2025 11:14:21 +1100 Subject: [PATCH 4/9] Test for doesn't raise --- tests/test_dependency_runtime_errors.py | 42 ++++++++++++++++++++----- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/tests/test_dependency_runtime_errors.py b/tests/test_dependency_runtime_errors.py index 5dd45976e..9fbf5b735 100644 --- a/tests/test_dependency_runtime_errors.py +++ b/tests/test_dependency_runtime_errors.py @@ -3,10 +3,11 @@ from anyio import open_file from fastapi import Depends, FastAPI from fastapi.testclient import TestClient -app = FastAPI() +app_reraises = FastAPI() +app_doesnt_reraise = FastAPI() -async def get_username(): +async def get_username_reraises(): try: async with await open_file("/path/to/sanchez.txt", "r") as f: yield await f.read() # pragma: no cover @@ -14,16 +15,41 @@ async def get_username(): raise RuntimeError("File something something, wubba lubba dub dub!") from ex -@app.get("/me") -def get_me(username: str = Depends(get_username)): +async def get_username_doesnt_reraise(): + try: + async with await open_file("/path/to/sanchez.txt", "r") as f: + yield await f.read() # pragma: no cover + except OSError: + print("We didn't re-raise, wubba lubba dub dub!") + + +@app_reraises.get("/me") +def get_me_reraises(username: str = Depends(get_username_reraises)): return username # pragma: no cover -client = TestClient(app) +@app_doesnt_reraise.get("/me") +def get_me_doesnt_reraise(username: str = Depends(get_username_doesnt_reraise)): + return username # pragma: no cover + + +client_reraises = TestClient(app_reraises) +client_doesnt_reraise = TestClient(app_doesnt_reraise) @pytest.mark.anyio -def test_runtime_error(): +def test_runtime_error_reraises(): with pytest.raises(RuntimeError) as exc_info: - client.get("/me") - assert "File something something" in exc_info.value.args[0] + client_reraises.get("/me") + assert str(exc_info.value) == "File something something, wubba lubba dub dub!" + + +@pytest.mark.anyio +def test_runtime_error_doesnt_reraise(): + with pytest.raises(RuntimeError) as exc_info: + client_doesnt_reraise.get("/me") + assert str(exc_info.value).startswith( + "Dependency get_username_doesnt_reraise raised: generator didn't yield. " + "There's a high chance that this is a dependency with yield that catches an " + "exception using except, but doesn't raise the exception again." + ) From 7dde3ee3463397a1adb21d0555f9b81979de42e2 Mon Sep 17 00:00:00 2001 From: Jeremy Epstein Date: Tue, 25 Nov 2025 09:43:55 +1100 Subject: [PATCH 5/9] self descriptive path Co-authored-by: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> --- tests/test_dependency_runtime_errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_dependency_runtime_errors.py b/tests/test_dependency_runtime_errors.py index 9fbf5b735..55251f78f 100644 --- a/tests/test_dependency_runtime_errors.py +++ b/tests/test_dependency_runtime_errors.py @@ -9,7 +9,7 @@ app_doesnt_reraise = FastAPI() async def get_username_reraises(): try: - async with await open_file("/path/to/sanchez.txt", "r") as f: + async with await open_file("/non_existing/path.txt", "r") as f: yield await f.read() # pragma: no cover except OSError as ex: raise RuntimeError("File something something, wubba lubba dub dub!") from ex From 1ea1ff58be0ad736be90e9cd64e7abce2f7b03dc Mon Sep 17 00:00:00 2001 From: Jeremy Epstein Date: Tue, 25 Nov 2025 09:44:09 +1100 Subject: [PATCH 6/9] more neutral text Co-authored-by: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> --- tests/test_dependency_runtime_errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_dependency_runtime_errors.py b/tests/test_dependency_runtime_errors.py index 55251f78f..2729ff106 100644 --- a/tests/test_dependency_runtime_errors.py +++ b/tests/test_dependency_runtime_errors.py @@ -12,7 +12,7 @@ async def get_username_reraises(): async with await open_file("/non_existing/path.txt", "r") as f: yield await f.read() # pragma: no cover except OSError as ex: - raise RuntimeError("File something something, wubba lubba dub dub!") from ex + raise RuntimeError("File read error") from ex async def get_username_doesnt_reraise(): From f4767e4854c4faf7200b97605017d0a56dd2338a Mon Sep 17 00:00:00 2001 From: Jeremy Epstein Date: Tue, 25 Nov 2025 09:50:14 +1100 Subject: [PATCH 7/9] single app object --- tests/test_dependency_runtime_errors.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/tests/test_dependency_runtime_errors.py b/tests/test_dependency_runtime_errors.py index 2729ff106..fe8f5e94e 100644 --- a/tests/test_dependency_runtime_errors.py +++ b/tests/test_dependency_runtime_errors.py @@ -3,13 +3,12 @@ from anyio import open_file from fastapi import Depends, FastAPI from fastapi.testclient import TestClient -app_reraises = FastAPI() -app_doesnt_reraise = FastAPI() +app = FastAPI() async def get_username_reraises(): try: - async with await open_file("/non_existing/path.txt", "r") as f: + async with await open_file("/nonexistent/path.txt", "r") as f: yield await f.read() # pragma: no cover except OSError as ex: raise RuntimeError("File read error") from ex @@ -17,37 +16,36 @@ async def get_username_reraises(): async def get_username_doesnt_reraise(): try: - async with await open_file("/path/to/sanchez.txt", "r") as f: + async with await open_file("/nonexistent/path.txt", "r") as f: yield await f.read() # pragma: no cover except OSError: - print("We didn't re-raise, wubba lubba dub dub!") + print("Didn't re-raise") -@app_reraises.get("/me") +@app.get("/reraises") def get_me_reraises(username: str = Depends(get_username_reraises)): return username # pragma: no cover -@app_doesnt_reraise.get("/me") +@app.get("/doesnt-reraise") def get_me_doesnt_reraise(username: str = Depends(get_username_doesnt_reraise)): return username # pragma: no cover -client_reraises = TestClient(app_reraises) -client_doesnt_reraise = TestClient(app_doesnt_reraise) +client = TestClient(app) @pytest.mark.anyio def test_runtime_error_reraises(): with pytest.raises(RuntimeError) as exc_info: - client_reraises.get("/me") - assert str(exc_info.value) == "File something something, wubba lubba dub dub!" + client.get("/reraises") + assert str(exc_info.value) == "File read error" @pytest.mark.anyio def test_runtime_error_doesnt_reraise(): with pytest.raises(RuntimeError) as exc_info: - client_doesnt_reraise.get("/me") + client.get("/doesnt-reraise") assert str(exc_info.value).startswith( "Dependency get_username_doesnt_reraise raised: generator didn't yield. " "There's a high chance that this is a dependency with yield that catches an " From 990301dc17476864d7ac17ad3c3f4a198c5a95ca Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:22:51 +0000 Subject: [PATCH 8/9] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/dependencies/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 582f284f1..4a2061aca 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -572,7 +572,7 @@ async def _solve_generator( cm = asynccontextmanager(dependant.call)(**sub_values) elif dependant.is_gen_callable: cm = contextmanager_in_threadpool(contextmanager(dependant.call)(**sub_values)) - + try: solved = await stack.enter_async_context(cm) except RuntimeError as ex: From c3eca6b05cf97dce689ff88841c2a86621fcff2c Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Mon, 8 Dec 2025 16:33:30 +0100 Subject: [PATCH 9/9] Add tests with sync dependency --- tests/test_dependency_runtime_errors.py | 50 +++++++++++++++++++++---- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/tests/test_dependency_runtime_errors.py b/tests/test_dependency_runtime_errors.py index fe8f5e94e..f091e7cda 100644 --- a/tests/test_dependency_runtime_errors.py +++ b/tests/test_dependency_runtime_errors.py @@ -6,7 +6,23 @@ from fastapi.testclient import TestClient app = FastAPI() -async def get_username_reraises(): +def get_username_reraises(): + try: + with open("/nonexistent/path.txt") as f: + yield f.read() # pragma: no cover + except OSError as ex: + raise RuntimeError("File read error") from ex + + +def get_username_doesnt_reraise(): + try: + with open("/nonexistent/path.txt") as f: + yield f.read() # pragma: no cover + except OSError: + print("Didn't re-raise") + + +async def get_username_reraises_async(): try: async with await open_file("/nonexistent/path.txt", "r") as f: yield await f.read() # pragma: no cover @@ -14,7 +30,7 @@ async def get_username_reraises(): raise RuntimeError("File read error") from ex -async def get_username_doesnt_reraise(): +async def get_username_doesnt_reraise_async(): try: async with await open_file("/nonexistent/path.txt", "r") as f: yield await f.read() # pragma: no cover @@ -32,22 +48,42 @@ def get_me_doesnt_reraise(username: str = Depends(get_username_doesnt_reraise)): return username # pragma: no cover +@app.get("/reraises-async") +def get_me_reraises_async(username: str = Depends(get_username_reraises_async)): + return username # pragma: no cover + + +@app.get("/doesnt-reraise-async") +def get_me_doesnt_reraise_async( + username: str = Depends(get_username_doesnt_reraise_async), +): + return username # pragma: no cover + + client = TestClient(app) @pytest.mark.anyio -def test_runtime_error_reraises(): +@pytest.mark.parametrize("path", ["/reraises", "/reraises-async"]) +def test_runtime_error_reraises(path: str): with pytest.raises(RuntimeError) as exc_info: - client.get("/reraises") + client.get(path) assert str(exc_info.value) == "File read error" @pytest.mark.anyio -def test_runtime_error_doesnt_reraise(): +@pytest.mark.parametrize( + ("path", "fn_name"), + [ + ("/doesnt-reraise", "get_username_doesnt_reraise"), + ("/doesnt-reraise-async", "get_username_doesnt_reraise_async"), + ], +) +def test_runtime_error_doesnt_reraise(path: str, fn_name: str): with pytest.raises(RuntimeError) as exc_info: - client.get("/doesnt-reraise") + client.get(path) assert str(exc_info.value).startswith( - "Dependency get_username_doesnt_reraise raised: generator didn't yield. " + f"Dependency {fn_name} raised: generator didn't yield. " "There's a high chance that this is a dependency with yield that catches an " "exception using except, but doesn't raise the exception again." )