]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
🐛 Fix unhandled growing memory for internal server errors, refactor dependencies...
authorSebastián Ramírez <tiangolo@gmail.com>
Sat, 24 Feb 2024 23:06:37 +0000 (00:06 +0100)
committerGitHub <noreply@github.com>
Sat, 24 Feb 2024 23:06:37 +0000 (00:06 +0100)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
17 files changed:
docs/en/docs/tutorial/dependencies/dependencies-with-yield.md
docs_src/dependencies/tutorial008c.py [new file with mode: 0644]
docs_src/dependencies/tutorial008c_an.py [new file with mode: 0644]
docs_src/dependencies/tutorial008c_an_py39.py [new file with mode: 0644]
docs_src/dependencies/tutorial008d.py [new file with mode: 0644]
docs_src/dependencies/tutorial008d_an.py [new file with mode: 0644]
docs_src/dependencies/tutorial008d_an_py39.py [new file with mode: 0644]
fastapi/routing.py
tests/test_dependency_contextmanager.py
tests/test_dependency_normal_exceptions.py
tests/test_tutorial/test_dependencies/test_tutorial008b_an_py39.py
tests/test_tutorial/test_dependencies/test_tutorial008c.py [new file with mode: 0644]
tests/test_tutorial/test_dependencies/test_tutorial008c_an.py [new file with mode: 0644]
tests/test_tutorial/test_dependencies/test_tutorial008c_an_py39.py [new file with mode: 0644]
tests/test_tutorial/test_dependencies/test_tutorial008d.py [new file with mode: 0644]
tests/test_tutorial/test_dependencies/test_tutorial008d_an.py [new file with mode: 0644]
tests/test_tutorial/test_dependencies/test_tutorial008d_an_py39.py [new file with mode: 0644]

index de87ba3156e5458a9f7c362dcf708d355528df46..ad5aed9323edd43efd1de008b4836bd3f31965fa 100644 (file)
@@ -162,6 +162,63 @@ The same way, you could raise an `HTTPException` or similar in the exit code, af
 
 An alternative you could use to catch exceptions (and possibly also raise another `HTTPException`) is to create a [Custom Exception Handler](../handling-errors.md#install-custom-exception-handlers){.internal-link target=_blank}.
 
+## Dependencies with `yield` and `except`
+
+If you catch an exception using `except` in a dependency with `yield` and you don't raise it again (or raise a new exception), FastAPI won't be able to notice there was an exception, the same way that would happen with regular Python:
+
+=== "Python 3.9+"
+
+    ```Python hl_lines="15-16"
+    {!> ../../../docs_src/dependencies/tutorial008c_an_py39.py!}
+    ```
+
+=== "Python 3.8+"
+
+    ```Python hl_lines="14-15"
+    {!> ../../../docs_src/dependencies/tutorial008c_an.py!}
+    ```
+
+=== "Python 3.8+ non-Annotated"
+
+    !!! tip
+        Prefer to use the `Annotated` version if possible.
+
+    ```Python hl_lines="13-14"
+    {!> ../../../docs_src/dependencies/tutorial008c.py!}
+    ```
+
+In this case, the client will see an *HTTP 500 Internal Server Error* response as it should, given that we are not raising an `HTTPException` or similar, but the server will **not have any logs** or any other indication of what was the error. 😱
+
+### Always `raise` in Dependencies with `yield` and `except`
+
+If you catch an exception in a dependency with `yield`, unless you are raising another `HTTPException` or similar, you should re-raise the original exception.
+
+You can re-raise the same exception using `raise`:
+
+=== "Python 3.9+"
+
+    ```Python hl_lines="17"
+    {!> ../../../docs_src/dependencies/tutorial008d_an_py39.py!}
+    ```
+
+=== "Python 3.8+"
+
+    ```Python hl_lines="16"
+    {!> ../../../docs_src/dependencies/tutorial008d_an.py!}
+    ```
+
+
+=== "Python 3.8+ non-Annotated"
+
+    !!! tip
+        Prefer to use the `Annotated` version if possible.
+
+    ```Python hl_lines="15"
+    {!> ../../../docs_src/dependencies/tutorial008d.py!}
+    ```
+
+Now the client will get the same *HTTP 500 Internal Server Error* response, but the server will have our custom `InternalError` in the logs. 😎
+
 ## 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.
@@ -187,7 +244,6 @@ participant tasks as Background tasks
         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
     end
@@ -210,15 +266,23 @@ participant tasks as Background tasks
 !!! tip
     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.
+    If you raise any exception, it will be passed to the dependencies with yield, including `HTTPException`. In most cases you will want to re-raise that same exception or a new one from the dependency with `yield` to make sure it's properly handled.
 
-## Dependencies with `yield`, `HTTPException` and Background Tasks
+## Dependencies with `yield`, `HTTPException`, `except` 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.
 
+### Dependencies with `yield` and `except`, Technical Details
+
+Before FastAPI 0.110.0, if you used a dependency with `yield`, and then you captured an exception with `except` in that dependency, and you didn't raise the exception again, the exception would be automatically raised/forwarded to any exception handlers or the internal server error handler.
+
+This was changed in version 0.110.0 to fix unhandled memory consumption from forwarded exceptions without a handler (internal server errors), and to make it consistent with the behavior of regular Python code.
+
+### Background Tasks and Dependencies with `yield`, Technical Details
+
 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.
diff --git a/docs_src/dependencies/tutorial008c.py b/docs_src/dependencies/tutorial008c.py
new file mode 100644 (file)
index 0000000..4b99a5a
--- /dev/null
@@ -0,0 +1,27 @@
+from fastapi import Depends, FastAPI, HTTPException
+
+app = FastAPI()
+
+
+class InternalError(Exception):
+    pass
+
+
+def get_username():
+    try:
+        yield "Rick"
+    except InternalError:
+        print("Oops, we didn't raise again, Britney 😱")
+
+
+@app.get("/items/{item_id}")
+def get_item(item_id: str, username: str = Depends(get_username)):
+    if item_id == "portal-gun":
+        raise InternalError(
+            f"The portal gun is too dangerous to be owned by {username}"
+        )
+    if item_id != "plumbus":
+        raise HTTPException(
+            status_code=404, detail="Item not found, there's only a plumbus here"
+        )
+    return item_id
diff --git a/docs_src/dependencies/tutorial008c_an.py b/docs_src/dependencies/tutorial008c_an.py
new file mode 100644 (file)
index 0000000..94f59f9
--- /dev/null
@@ -0,0 +1,28 @@
+from fastapi import Depends, FastAPI, HTTPException
+from typing_extensions import Annotated
+
+app = FastAPI()
+
+
+class InternalError(Exception):
+    pass
+
+
+def get_username():
+    try:
+        yield "Rick"
+    except InternalError:
+        print("Oops, we didn't raise again, Britney 😱")
+
+
+@app.get("/items/{item_id}")
+def get_item(item_id: str, username: Annotated[str, Depends(get_username)]):
+    if item_id == "portal-gun":
+        raise InternalError(
+            f"The portal gun is too dangerous to be owned by {username}"
+        )
+    if item_id != "plumbus":
+        raise HTTPException(
+            status_code=404, detail="Item not found, there's only a plumbus here"
+        )
+    return item_id
diff --git a/docs_src/dependencies/tutorial008c_an_py39.py b/docs_src/dependencies/tutorial008c_an_py39.py
new file mode 100644 (file)
index 0000000..da92efa
--- /dev/null
@@ -0,0 +1,29 @@
+from typing import Annotated
+
+from fastapi import Depends, FastAPI, HTTPException
+
+app = FastAPI()
+
+
+class InternalError(Exception):
+    pass
+
+
+def get_username():
+    try:
+        yield "Rick"
+    except InternalError:
+        print("Oops, we didn't raise again, Britney 😱")
+
+
+@app.get("/items/{item_id}")
+def get_item(item_id: str, username: Annotated[str, Depends(get_username)]):
+    if item_id == "portal-gun":
+        raise InternalError(
+            f"The portal gun is too dangerous to be owned by {username}"
+        )
+    if item_id != "plumbus":
+        raise HTTPException(
+            status_code=404, detail="Item not found, there's only a plumbus here"
+        )
+    return item_id
diff --git a/docs_src/dependencies/tutorial008d.py b/docs_src/dependencies/tutorial008d.py
new file mode 100644 (file)
index 0000000..9303934
--- /dev/null
@@ -0,0 +1,28 @@
+from fastapi import Depends, FastAPI, HTTPException
+
+app = FastAPI()
+
+
+class InternalError(Exception):
+    pass
+
+
+def get_username():
+    try:
+        yield "Rick"
+    except InternalError:
+        print("We don't swallow the internal error here, we raise again 😎")
+        raise
+
+
+@app.get("/items/{item_id}")
+def get_item(item_id: str, username: str = Depends(get_username)):
+    if item_id == "portal-gun":
+        raise InternalError(
+            f"The portal gun is too dangerous to be owned by {username}"
+        )
+    if item_id != "plumbus":
+        raise HTTPException(
+            status_code=404, detail="Item not found, there's only a plumbus here"
+        )
+    return item_id
diff --git a/docs_src/dependencies/tutorial008d_an.py b/docs_src/dependencies/tutorial008d_an.py
new file mode 100644 (file)
index 0000000..c354245
--- /dev/null
@@ -0,0 +1,29 @@
+from fastapi import Depends, FastAPI, HTTPException
+from typing_extensions import Annotated
+
+app = FastAPI()
+
+
+class InternalError(Exception):
+    pass
+
+
+def get_username():
+    try:
+        yield "Rick"
+    except InternalError:
+        print("We don't swallow the internal error here, we raise again 😎")
+        raise
+
+
+@app.get("/items/{item_id}")
+def get_item(item_id: str, username: Annotated[str, Depends(get_username)]):
+    if item_id == "portal-gun":
+        raise InternalError(
+            f"The portal gun is too dangerous to be owned by {username}"
+        )
+    if item_id != "plumbus":
+        raise HTTPException(
+            status_code=404, detail="Item not found, there's only a plumbus here"
+        )
+    return item_id
diff --git a/docs_src/dependencies/tutorial008d_an_py39.py b/docs_src/dependencies/tutorial008d_an_py39.py
new file mode 100644 (file)
index 0000000..99bd5cb
--- /dev/null
@@ -0,0 +1,30 @@
+from typing import Annotated
+
+from fastapi import Depends, FastAPI, HTTPException
+
+app = FastAPI()
+
+
+class InternalError(Exception):
+    pass
+
+
+def get_username():
+    try:
+        yield "Rick"
+    except InternalError:
+        print("We don't swallow the internal error here, we raise again 😎")
+        raise
+
+
+@app.get("/items/{item_id}")
+def get_item(item_id: str, username: Annotated[str, Depends(get_username)]):
+    if item_id == "portal-gun":
+        raise InternalError(
+            f"The portal gun is too dangerous to be owned by {username}"
+        )
+    if item_id != "plumbus":
+        raise HTTPException(
+            status_code=404, detail="Item not found, there's only a plumbus here"
+        )
+    return item_id
index acebabfca0566861e80cc77e91aaebdf5e115967..23a32d15fd452cac8379a69dbd21a327155153ff 100644 (file)
@@ -216,19 +216,14 @@ def get_request_handler(
         actual_response_class = response_class
 
     async def app(request: Request) -> Response:
-        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
+        async with AsyncExitStack() as file_stack:
             try:
                 body: Any = None
                 if body_field:
                     if is_body_form:
                         body = await request.form()
-                        async_exit_stack.push_async_callback(body.close)
+                        file_stack.push_async_callback(body.close)
                     else:
                         body_bytes = await request.body()
                         if body_bytes:
@@ -260,18 +255,17 @@ def get_request_handler(
                     ],
                     body=e.doc,
                 )
-                exception_to_reraise = validation_error
                 raise validation_error from e
-            except HTTPException as e:
-                exception_to_reraise = e
+            except HTTPException:
+                # If a middleware raises an HTTPException, it should be raised again
                 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:
+            errors: List[Any] = []
+            async with AsyncExitStack() as async_exit_stack:
                 solved_result = await solve_dependencies(
                     request=request,
                     dependant=dependant,
@@ -280,59 +274,53 @@ def get_request_handler(
                     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 not errors:
+                    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
+                        response = raw_response
+                    else:
+                        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)
             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:
-                    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"
+        if response is None:
+            raise FastAPIError(
+                "No response object was returned. There's a high chance that the "
+                "application code is raising an exception and a dependency with yield "
+                "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"
+            )
         return response
 
     return app
index b07f9aa5b6c6b3236206a4f4e1c805c38750c962..008dab7bc74cbe07f911de17e1d258779f61918f 100644 (file)
@@ -55,6 +55,7 @@ async def asyncgen_state_try(state: Dict[str, str] = Depends(get_state)):
         yield state["/async_raise"]
     except AsyncDependencyError:
         errors.append("/async_raise")
+        raise
     finally:
         state["/async_raise"] = "asyncgen raise finalized"
 
@@ -65,6 +66,7 @@ def generator_state_try(state: Dict[str, str] = Depends(get_state)):
         yield state["/sync_raise"]
     except SyncDependencyError:
         errors.append("/sync_raise")
+        raise
     finally:
         state["/sync_raise"] = "generator raise finalized"
 
index 23c366d5d7a6fdb208d1e03c6ad91cdc60a535a8..326f8fd88b832f4f993314c2f59d4c542bc31ac7 100644 (file)
@@ -20,6 +20,7 @@ async def get_database():
         fake_database.update(temp_database)
     except HTTPException:
         state["except"] = True
+        raise
     finally:
         state["finally"] = True
 
index 7f51fc52a5133fef625ba263869853736368c9b7..7d24809a8a75677fd9d45c98694db94d23993cdc 100644 (file)
@@ -1,23 +1,33 @@
+import pytest
 from fastapi.testclient import TestClient
 
-from docs_src.dependencies.tutorial008b_an import app
+from ...utils import needs_py39
 
-client = TestClient(app)
 
+@pytest.fixture(name="client")
+def get_client():
+    from docs_src.dependencies.tutorial008b_an_py39 import app
 
-def test_get_no_item():
+    client = TestClient(app)
+    return client
+
+
+@needs_py39
+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():
+@needs_py39
+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():
+@needs_py39
+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"}
diff --git a/tests/test_tutorial/test_dependencies/test_tutorial008c.py b/tests/test_tutorial/test_dependencies/test_tutorial008c.py
new file mode 100644 (file)
index 0000000..27be889
--- /dev/null
@@ -0,0 +1,38 @@
+import pytest
+from fastapi.exceptions import FastAPIError
+from fastapi.testclient import TestClient
+
+
+@pytest.fixture(name="client")
+def get_client():
+    from docs_src.dependencies.tutorial008c import app
+
+    client = TestClient(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, there's only a plumbus here"}
+
+
+def test_get(client: TestClient):
+    response = client.get("/items/plumbus")
+    assert response.status_code == 200, response.text
+    assert response.json() == "plumbus"
+
+
+def test_fastapi_error(client: TestClient):
+    with pytest.raises(FastAPIError) as exc_info:
+        client.get("/items/portal-gun")
+    assert "No response object was returned" in exc_info.value.args[0]
+
+
+def test_internal_server_error():
+    from docs_src.dependencies.tutorial008c import app
+
+    client = TestClient(app, raise_server_exceptions=False)
+    response = client.get("/items/portal-gun")
+    assert response.status_code == 500, response.text
+    assert response.text == "Internal Server Error"
diff --git a/tests/test_tutorial/test_dependencies/test_tutorial008c_an.py b/tests/test_tutorial/test_dependencies/test_tutorial008c_an.py
new file mode 100644 (file)
index 0000000..10fa1ab
--- /dev/null
@@ -0,0 +1,38 @@
+import pytest
+from fastapi.exceptions import FastAPIError
+from fastapi.testclient import TestClient
+
+
+@pytest.fixture(name="client")
+def get_client():
+    from docs_src.dependencies.tutorial008c_an import app
+
+    client = TestClient(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, there's only a plumbus here"}
+
+
+def test_get(client: TestClient):
+    response = client.get("/items/plumbus")
+    assert response.status_code == 200, response.text
+    assert response.json() == "plumbus"
+
+
+def test_fastapi_error(client: TestClient):
+    with pytest.raises(FastAPIError) as exc_info:
+        client.get("/items/portal-gun")
+    assert "No response object was returned" in exc_info.value.args[0]
+
+
+def test_internal_server_error():
+    from docs_src.dependencies.tutorial008c_an import app
+
+    client = TestClient(app, raise_server_exceptions=False)
+    response = client.get("/items/portal-gun")
+    assert response.status_code == 500, response.text
+    assert response.text == "Internal Server Error"
diff --git a/tests/test_tutorial/test_dependencies/test_tutorial008c_an_py39.py b/tests/test_tutorial/test_dependencies/test_tutorial008c_an_py39.py
new file mode 100644 (file)
index 0000000..6c3acff
--- /dev/null
@@ -0,0 +1,44 @@
+import pytest
+from fastapi.exceptions import FastAPIError
+from fastapi.testclient import TestClient
+
+from ...utils import needs_py39
+
+
+@pytest.fixture(name="client")
+def get_client():
+    from docs_src.dependencies.tutorial008c_an_py39 import app
+
+    client = TestClient(app)
+    return client
+
+
+@needs_py39
+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, there's only a plumbus here"}
+
+
+@needs_py39
+def test_get(client: TestClient):
+    response = client.get("/items/plumbus")
+    assert response.status_code == 200, response.text
+    assert response.json() == "plumbus"
+
+
+@needs_py39
+def test_fastapi_error(client: TestClient):
+    with pytest.raises(FastAPIError) as exc_info:
+        client.get("/items/portal-gun")
+    assert "No response object was returned" in exc_info.value.args[0]
+
+
+@needs_py39
+def test_internal_server_error():
+    from docs_src.dependencies.tutorial008c_an_py39 import app
+
+    client = TestClient(app, raise_server_exceptions=False)
+    response = client.get("/items/portal-gun")
+    assert response.status_code == 500, response.text
+    assert response.text == "Internal Server Error"
diff --git a/tests/test_tutorial/test_dependencies/test_tutorial008d.py b/tests/test_tutorial/test_dependencies/test_tutorial008d.py
new file mode 100644 (file)
index 0000000..0434961
--- /dev/null
@@ -0,0 +1,41 @@
+import pytest
+from fastapi.testclient import TestClient
+
+
+@pytest.fixture(name="client")
+def get_client():
+    from docs_src.dependencies.tutorial008d import app
+
+    client = TestClient(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, there's only a plumbus here"}
+
+
+def test_get(client: TestClient):
+    response = client.get("/items/plumbus")
+    assert response.status_code == 200, response.text
+    assert response.json() == "plumbus"
+
+
+def test_internal_error(client: TestClient):
+    from docs_src.dependencies.tutorial008d import InternalError
+
+    with pytest.raises(InternalError) as exc_info:
+        client.get("/items/portal-gun")
+    assert (
+        exc_info.value.args[0] == "The portal gun is too dangerous to be owned by Rick"
+    )
+
+
+def test_internal_server_error():
+    from docs_src.dependencies.tutorial008d import app
+
+    client = TestClient(app, raise_server_exceptions=False)
+    response = client.get("/items/portal-gun")
+    assert response.status_code == 500, response.text
+    assert response.text == "Internal Server Error"
diff --git a/tests/test_tutorial/test_dependencies/test_tutorial008d_an.py b/tests/test_tutorial/test_dependencies/test_tutorial008d_an.py
new file mode 100644 (file)
index 0000000..f29d8cd
--- /dev/null
@@ -0,0 +1,41 @@
+import pytest
+from fastapi.testclient import TestClient
+
+
+@pytest.fixture(name="client")
+def get_client():
+    from docs_src.dependencies.tutorial008d_an import app
+
+    client = TestClient(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, there's only a plumbus here"}
+
+
+def test_get(client: TestClient):
+    response = client.get("/items/plumbus")
+    assert response.status_code == 200, response.text
+    assert response.json() == "plumbus"
+
+
+def test_internal_error(client: TestClient):
+    from docs_src.dependencies.tutorial008d_an import InternalError
+
+    with pytest.raises(InternalError) as exc_info:
+        client.get("/items/portal-gun")
+    assert (
+        exc_info.value.args[0] == "The portal gun is too dangerous to be owned by Rick"
+    )
+
+
+def test_internal_server_error():
+    from docs_src.dependencies.tutorial008d_an import app
+
+    client = TestClient(app, raise_server_exceptions=False)
+    response = client.get("/items/portal-gun")
+    assert response.status_code == 500, response.text
+    assert response.text == "Internal Server Error"
diff --git a/tests/test_tutorial/test_dependencies/test_tutorial008d_an_py39.py b/tests/test_tutorial/test_dependencies/test_tutorial008d_an_py39.py
new file mode 100644 (file)
index 0000000..0a585f4
--- /dev/null
@@ -0,0 +1,47 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from ...utils import needs_py39
+
+
+@pytest.fixture(name="client")
+def get_client():
+    from docs_src.dependencies.tutorial008d_an_py39 import app
+
+    client = TestClient(app)
+    return client
+
+
+@needs_py39
+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, there's only a plumbus here"}
+
+
+@needs_py39
+def test_get(client: TestClient):
+    response = client.get("/items/plumbus")
+    assert response.status_code == 200, response.text
+    assert response.json() == "plumbus"
+
+
+@needs_py39
+def test_internal_error(client: TestClient):
+    from docs_src.dependencies.tutorial008d_an_py39 import InternalError
+
+    with pytest.raises(InternalError) as exc_info:
+        client.get("/items/portal-gun")
+    assert (
+        exc_info.value.args[0] == "The portal gun is too dangerous to be owned by Rick"
+    )
+
+
+@needs_py39
+def test_internal_server_error():
+    from docs_src.dependencies.tutorial008d_an_py39 import app
+
+    client = TestClient(app, raise_server_exceptions=False)
+    response = client.get("/items/portal-gun")
+    assert response.status_code == 500, response.text
+    assert response.text == "Internal Server Error"