From 65bf29d55a05f4463acd24a6cb8699e27e176527 Mon Sep 17 00:00:00 2001 From: Herrtian <70463940+Herrtian@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:38:26 +0200 Subject: [PATCH] fix: include implicit HEAD in allow header --- fastapi/routing.py | 21 ++++++++++++++++++++- tests/test_auto_head_for_get.py | 14 ++++++++++++++ tests/test_router_include_context.py | 7 +++++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index 2520e3a6e..8420907f8 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -1166,6 +1166,12 @@ class APIRoute(routing.Route): and "HEAD" not in route_methods ) + def _allow_methods(self, methods: Collection[str]) -> set[str]: + allow_methods = set(methods) + if "GET" in allow_methods and "HEAD" not in allow_methods: + allow_methods.add("HEAD") + return allow_methods + def matches(self, scope: Scope) -> tuple[Match, Scope]: effective_context = _get_scope_effective_route_context(scope) if effective_context is not None and effective_context.original_route is self: @@ -1191,7 +1197,7 @@ class APIRoute(routing.Route): and scope["method"] not in methods and not self._is_head_for_get(scope, methods) ): - headers = {"Allow": ", ".join(methods)} + headers = {"Allow": ", ".join(self._allow_methods(methods))} if "app" in scope: raise HTTPException(status_code=405, headers=headers) response = PlainTextResponse( @@ -1206,6 +1212,19 @@ class APIRoute(routing.Route): _effective_route_context_var.reset(token) await app(scope, receive, send) return + if ( + self.methods + and scope["method"] not in self.methods + and not self._is_head_for_get(scope) + ): + headers = {"Allow": ", ".join(self._allow_methods(self.methods))} + if "app" in scope: + raise HTTPException(status_code=405, headers=headers) + response = PlainTextResponse( + "Method Not Allowed", status_code=405, headers=headers + ) + await response(scope, receive, send) + return # Allow HEAD requests through to the GET handler. if self._is_head_for_get(scope): await self.app(scope, receive, send) diff --git a/tests/test_auto_head_for_get.py b/tests/test_auto_head_for_get.py index 5eb1a3b72..e63c35ecc 100644 --- a/tests/test_auto_head_for_get.py +++ b/tests/test_auto_head_for_get.py @@ -62,6 +62,20 @@ def test_head_not_in_openapi_schema(): assert list(schema["paths"]["/items/{item_id}"].keys()) == ["get"] +def test_allow_header_includes_implicit_head_for_get_route(): + app = FastAPI() + + @app.get("/items") + def read_items(): + return [] + + client = TestClient(app) + response = client.post("/items") + + assert response.status_code == 405 + assert set(response.headers["allow"].split(", ")) == {"GET", "HEAD"} + + def test_head_not_added_to_non_get_routes(): app = FastAPI() diff --git a/tests/test_router_include_context.py b/tests/test_router_include_context.py index 408cdd3f1..62eebcff5 100644 --- a/tests/test_router_include_context.py +++ b/tests/test_router_include_context.py @@ -532,7 +532,7 @@ def test_included_partial_match_returns_405_when_no_later_full_match_exists(): response = TestClient(app).post("/api/items") assert response.status_code == 405 - assert response.headers["allow"] == "GET" + assert set(response.headers["allow"].split(", ")) == {"GET", "HEAD"} def test_included_slash_redirect_does_not_block_later_exact_match(): @@ -682,7 +682,10 @@ async def test_included_api_route_without_app_scope_returns_405_response(): assert messages[0]["type"] == "http.response.start" assert messages[0]["status"] == 405 - assert dict(messages[0]["headers"])[b"allow"] == b"GET" + assert set(dict(messages[0]["headers"])[b"allow"].decode().split(", ")) == { + "GET", + "HEAD", + } def test_effective_api_route_context_does_not_match_websocket_scope():