From 112b011fd6fd138571bcf5de0d95e6e6d39da01c Mon Sep 17 00:00:00 2001 From: serverestaa Date: Fri, 3 Jul 2026 04:19:18 +0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20frontend=20dependencies=20?= =?UTF-8?q?not=20propagating=20response=20headers,=20cookies,=20and=20back?= =?UTF-8?q?ground=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/routing.py | 41 +++++++++-- tests/test_frontend.py | 150 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 185 insertions(+), 6 deletions(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index c442b122b..aa17cade2 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -49,6 +49,7 @@ from fastapi._compat import ( from fastapi.datastructures import Default, DefaultPlaceholder from fastapi.dependencies.models import Dependant from fastapi.dependencies.utils import ( + SolvedDependency, _should_embed_body_fields, get_body_field, get_dependant, @@ -103,7 +104,7 @@ from starlette.routing import ( ) from starlette.routing import Mount as Mount # noqa from starlette.staticfiles import StaticFiles -from starlette.types import AppType, ASGIApp, Lifespan, Receive, Scope, Send +from starlette.types import AppType, ASGIApp, Lifespan, Message, Receive, Scope, Send from starlette.websockets import WebSocket from typing_extensions import deprecated @@ -2156,8 +2157,35 @@ class _FrontendRouteGroup(BaseRoute): dependant=dependant, dependency_overrides_provider=dependency_overrides_provider, embed_body_fields=embed_body_fields, - ): - await route.handle(scope, receive, send) + ) as solved_result: + dependency_response = solved_result.response + body_allowed = True + + async def send_with_dependency_response(message: Message) -> None: + nonlocal body_allowed + if message["type"] == "http.response.start": + # Only override a plain 200, semantic statuses from the + # file response (e.g. 304 Not Modified, 206 Partial + # Content) are preserved. + if dependency_response.status_code and message["status"] == 200: + message["status"] = dependency_response.status_code + headers = list(message.get("headers", [])) + headers.extend(dependency_response.headers.raw) + if not is_body_allowed_for_status_code(message["status"]): + body_allowed = False + headers = [ + (name, value) + for name, value in headers + if name.lower() != b"content-length" + ] + message["headers"] = headers + elif message["type"] == "http.response.body" and not body_allowed: + message["body"] = b"" + await send(message) + + await route.handle(scope, receive, send_with_dependency_response) + if solved_result.background_tasks: + await solved_result.background_tasks() return await route.handle(scope, receive, send) @@ -2177,7 +2205,7 @@ class _FrontendRouteGroup(BaseRoute): dependant: Dependant, dependency_overrides_provider: Any | None, embed_body_fields: bool, - ) -> AsyncIterator[None]: + ) -> AsyncIterator[SolvedDependency]: request = Request(scope, receive, send) previous_inner_astack = scope.get("fastapi_inner_astack", _SCOPE_MISSING) previous_function_astack = scope.get("fastapi_function_astack", _SCOPE_MISSING) @@ -2195,7 +2223,10 @@ class _FrontendRouteGroup(BaseRoute): ) if solved_result.errors: raise RequestValidationError(solved_result.errors) - yield + # Function-scoped dependencies exit before the response is + # sent and before background tasks run, as in request_response() + # above for regular routes. + yield solved_result finally: if previous_inner_astack is _SCOPE_MISSING: scope.pop("fastapi_inner_astack", None) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index b8dd62fb8..6759834aa 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -1,13 +1,22 @@ import errno import os import runpy +from collections.abc import Iterator from contextlib import AsyncExitStack from pathlib import Path from typing import Literal import anyio import pytest -from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, WebSocket +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + FastAPI, + HTTPException, + Request, + WebSocket, +) from fastapi.testclient import TestClient from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.responses import PlainTextResponse, Response @@ -515,6 +524,145 @@ def test_frontend_dependency_validation_errors_return_422(tmp_path: Path): } +def test_frontend_dependency_response_headers_and_cookies(tmp_path: Path): + def refresh_session(response: Response) -> None: + response.set_cookie("session", "refreshed") + response.headers["X-Dep"] = "ran" + + dist = tmp_path / "dist" + write_file(dist / "index.html", "app") + write_file(dist / "assets" / "app.js", "console.log('ok')") + app = FastAPI(dependencies=[Depends(refresh_session)]) + app.frontend("/", directory=dist, fallback="index.html") + client = TestClient(app) + + response = client.get("/") + assert response.status_code == 200 + assert response.text == "app" + assert response.cookies["session"] == "refreshed" + assert response.headers["x-dep"] == "ran" + + response = client.get("/assets/app.js") + assert response.status_code == 200 + assert response.cookies["session"] == "refreshed" + assert response.headers["x-dep"] == "ran" + + response = client.get("/dashboard", headers={"accept": "text/html"}) + assert response.status_code == 200 + assert response.text == "app" + assert response.cookies["session"] == "refreshed" + assert response.headers["x-dep"] == "ran" + + +def test_frontend_dependency_response_status_code(tmp_path: Path): + def set_status(response: Response) -> None: + response.status_code = 203 + + dist = tmp_path / "dist" + write_file(dist / "index.html", "app") + app = FastAPI(dependencies=[Depends(set_status)]) + app.frontend("/", directory=dist) + + response = TestClient(app).get("/") + + assert response.status_code == 203 + assert response.text == "app" + + +def test_frontend_dependency_does_not_override_304_not_modified(tmp_path: Path): + def set_status_and_header(response: Response) -> None: + response.status_code = 203 + response.headers["X-Dep"] = "ran" + + dist = tmp_path / "dist" + write_file(dist / "index.html", "app") + app = FastAPI(dependencies=[Depends(set_status_and_header)]) + app.frontend("/", directory=dist) + client = TestClient(app) + + response = client.get("/") + assert response.status_code == 203 + etag = response.headers["etag"] + + response = client.get("/", headers={"If-None-Match": etag}) + assert response.status_code == 304 + assert response.text == "" + assert response.headers["x-dep"] == "ran" + + +def test_frontend_dependency_does_not_override_206_partial_content(tmp_path: Path): + def set_status_and_header(response: Response) -> None: + response.status_code = 203 + response.headers["X-Dep"] = "ran" + + dist = tmp_path / "dist" + write_file(dist / "index.html", "app") + app = FastAPI(dependencies=[Depends(set_status_and_header)]) + app.frontend("/", directory=dist) + + response = TestClient(app).get("/", headers={"Range": "bytes=0-1"}) + + assert response.status_code == 206 + assert response.text == "ap" + assert response.headers["x-dep"] == "ran" + + +def test_frontend_dependency_bodyless_status_code_strips_body(tmp_path: Path): + def set_status(response: Response) -> None: + response.status_code = 304 + + dist = tmp_path / "dist" + write_file(dist / "index.html", "app") + app = FastAPI(dependencies=[Depends(set_status)]) + app.frontend("/", directory=dist) + + response = TestClient(app).get("/") + + assert response.status_code == 304 + assert response.text == "" + assert "content-length" not in response.headers + + +def test_frontend_dependency_background_tasks_run(tmp_path: Path): + tasks_run: list[str] = [] + + def add_task(background_tasks: BackgroundTasks) -> None: + background_tasks.add_task(tasks_run.append, "ran") + + dist = tmp_path / "dist" + write_file(dist / "index.html", "app") + app = FastAPI(dependencies=[Depends(add_task)]) + app.frontend("/", directory=dist) + client = TestClient(app) + + response = client.get("/") + assert response.status_code == 200 + assert response.text == "app" + assert tasks_run == ["ran"] + + +def test_frontend_function_scoped_yield_dependency_exits_before_background_tasks( + tmp_path: Path, +): + events: list[str] = [] + + def dep(background_tasks: BackgroundTasks) -> Iterator[None]: + background_tasks.add_task(events.append, "background") + yield + events.append("teardown") + + dist = tmp_path / "dist" + write_file(dist / "index.html", "app") + app = FastAPI(dependencies=[Depends(dep, scope="function")]) + app.frontend("/", directory=dist) + client = TestClient(app) + + response = client.get("/") + assert response.status_code == 200 + assert response.text == "app" + assert events == ["teardown", "background"] + + @pytest.mark.anyio async def test_frontend_dependency_restores_existing_dependency_stacks( tmp_path: Path,