Skip to content

fix(routing): implement proper HEAD support for static routes#2674

Open
zain-asif-dev wants to merge 2 commits into
falconry:masterfrom
zain-asif-dev:fix/static-route-head-support
Open

fix(routing): implement proper HEAD support for static routes#2674
zain-asif-dev wants to merge 2 commits into
falconry:masterfrom
zain-asif-dev:fix/static-route-head-support

Conversation

@zain-asif-dev

Copy link
Copy Markdown

Summary

Closes #2337.

Static routes (StaticRoute/StaticRouteAsync, as configured via App.add_static_route()) didn't have proper HEAD support:

  • A HEAD request was handled exactly like GET: the responder always opened the underlying file via io.open()/os.fstat() and set it as resp.stream, even though a HEAD response never includes a body. Since the body is discarded later in the response pipeline (resp.stream is never iterated for HEAD), the opened file handle was never closed — every HEAD request to a static route leaked a file descriptor.
  • Any other HTTP method (POST, PUT, DELETE, etc.) was also accepted and served the file's contents, instead of being rejected.
  • The Allow/Access-Control-Allow-Methods header returned for OPTIONS requests only advertised GET.

Changes

  • HEAD requests are now served from os.stat() directly, without ever opening a file handle, while still setting the same headers a GET would (Content-Length, ETag, Last-Modified, Accept-Ranges, and Content-Range/206 for ranged requests).
  • Added _compute_head_range(), which mirrors the offset/length arithmetic of the existing _set_range() helper, but operates purely on the file size (no file handle required).
  • Requests with methods other than GET, HEAD, and OPTIONS now get a 405 Method Not Allowed response (via falcon.HTTPMethodNotAllowed(['GET', 'HEAD'])) instead of silently being served the file.
  • The OPTIONS responder now sets Allow: GET, HEAD instead of Allow: GET.
  • Extracted the "resolve file honoring fallback_filename" logic into a small _locate() helper shared between the GET and HEAD code paths, to keep __call__()'s complexity in check.

Test plan

  • Extended the patch_open test fixture in tests/test_static.py to also fake os.stat() (used by the new HEAD code path), in addition to the existing io.open()/os.fstat() faking used by GET.
  • Added new tests to tests/test_static.py:
    • test_head_request: basic HEAD returns 200 with correct headers and no body, and never opens a file handle.
    • test_head_request_not_found: HEAD for a missing file returns 404.
    • test_head_request_not_modified: HEAD honors If-None-Match/ETag and returns 304.
    • test_head_request_range (parametrized): HEAD with a Range header returns the correct Content-Range/Content-Length/206 (or 200 when no range), with an empty body.
    • test_head_request_range_not_satisfiable: HEAD with an out-of-bounds Range returns 416.
    • test_method_not_allowed (parametrized over POST/PUT/PATCH/DELETE): returns 405 with Allow: GET, HEAD.
  • Updated test_options_request (in tests/test_static.py) and test_enabled_cors_static_route (in tests/test_cors_middleware.py) to expect the now-correct GET, HEAD Allow header.
  • Verified all new tests fail on the pre-fix code and pass with the fix.
  • Ran the full test suite (pytest tests/): 3977 passed, 458 skipped, with only one pre-existing failure (test_select_subprotocol_known, an unrelated flaky uvicorn WebSocket test) reproducible on master as well.
  • ruff check, ruff format --diff, and mypy all pass clean on the modified files.
  • Added a towncrier news fragment (docs/_newsfragments/2337.bugfix.rst) and verified it renders correctly via towncrier build --draft.

Static routes previously handled a HEAD request the same way as GET:
they would open the underlying file (via io.open()/os.fstat()) and set
it as the response stream, even though a HEAD response never includes
a body. Since the body is discarded later in the response-handling
pipeline without ever being iterated, the opened file handle was never
closed, leaking a file descriptor on every HEAD request to a static
route.

HEAD requests are now served directly from the file's stat() result,
without opening a file handle, while still returning the same headers
(Content-Length, ETag, Last-Modified, Accept-Ranges, and Content-Range
for ranged requests) that the equivalent GET would have produced.

As a bonus, static routes now reject methods other than GET, HEAD, and
OPTIONS with 405 Method Not Allowed, instead of serving the file's
contents regardless of method. The Allow/Access-Control-Allow-Methods
header returned for OPTIONS requests now also correctly advertises
"GET, HEAD" rather than just "GET".

Closes falconry#2337

Signed-off-by: Zain Asif <zainasif.jutt1@gmail.com>
@codecov

codecov Bot commented Jul 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (045a484) to head (0bb3178).

Additional details and impacted files
@@            Coverage Diff            @@
##            master     #2674   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           64        64           
  Lines         7985      8026   +41     
  Branches      1103      1113   +10     
=========================================
+ Hits          7985      8026   +41     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Signed-off-by: Zain Asif <zainasif.jutt1@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proper HEAD support for static routes

1 participant