Skip to content

Answer a HEAD request for a path no route matches without a body - #2958

Open
ericproulx wants to merge 1 commit into
masterfrom
fix/router-head-404-body
Open

ericproulx wants to merge 1 commit into
masterfrom
fix/router-head-404-body

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

Summary

Rack requires an empty body in a response to a HEAD request. Every endpoint strips its own body with the Rack::Head at the top of its stack. A path that nothing matches, though, is answered by Router#default_response, which no endpoint builds. So a HEAD request for such a path gets 404 Not Found back as its body, and Rack::Lint raises on it:

Rack::Lint::LintError: Response body was given for HEAD request, but should be empty

rackup adds Rack::Lint in development, so a HEAD request for an unknown path fails there instead of returning a 404. The same happens on 3.3.5.

lint! and Grape.config.lint (which the suite turns on in spec_helper) add Rack::Lint to each endpoint's stack only, so they never see this response. Wrapping the whole API in Rack::Lint does. Running the suite that way shows this is the only Rack SPEC violation Grape produces: the two anchoring HEAD examples in endpoint_spec fail, and nothing else does. A follow-up PR makes lint! wrap the router too, so the suite catches this kind of response itself.

The router now answers HEAD with an empty body when nothing matches. Every other method keeps 404 Not Found.

Test plan

  • New router_spec example (HEAD body empty, GET body unchanged) fails on master and passes here.
  • Full suite passes with every rack-test app wrapped in Rack::Lint (only the lint! spec that expects its error logs one).
  • Full RSpec suite passes locally (2917 examples).
  • RuboCop clean.
  • CI green.

🤖 Generated with Claude Code

Rack requires the body of a response to a HEAD request to be empty. Every
endpoint strips its own with the Rack::Head at the top of its stack, but a
path nothing matches is answered by Router#default_response, which no
endpoint builds -- so a HEAD for it came back with `404 Not Found` as its
body, and Rack::Lint raises on that response:

    Response body was given for HEAD request, but should be empty

`lint!` and `Grape.config.lint` add Rack::Lint to each endpoint's stack, so
they never see this response either; wrapping a whole API in Rack::Lint
does, and running the suite that way shows it is the only response Grape
builds that breaks the SPEC (the anchoring HEAD examples in endpoint_spec).

The router now answers HEAD with an empty body when nothing matched. Every
other method keeps `404 Not Found`.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@ericproulx
ericproulx force-pushed the fix/router-head-404-body branch from 9917f42 to 627bb32 Compare September 18, 2026 14:35
@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

ericproulx added a commit that referenced this pull request Sep 18, 2026
`lint!` and Grape.config.lint put Rack::Lint in each endpoint's stack, so
two kinds of response never met it: the 404 the router answers for a path
nothing matches, which no endpoint builds, and a Rack app mounted with
`mount`, which the router calls directly instead of through a stack. That
is how a HEAD request for an unknown path answered with a body (#2958)
while the suite, which lints every API, stayed green.

A linted API now hands its router `lint: true`, and the router answers that
404 through Rack::Lint, request and response alike. A bare Rack app's
endpoint wraps the app in Rack::Lint when its scope is linted.

Wrapping the whole router instead would lint every endpoint's response
twice, and two nested Rack::Lints cannot survive a middleware that calls
`to_ary` on the body -- the inner one closes it, the outer one then
iterates it -- which Rack::ETag does in any Rails app mounting the API; the
Rails integration spec fails that way. So only the two responses that
bypass an endpoint's Lint gain one.

The suite's own lint then flagged two fixtures: `API.call({})`, an env
Rack::Lint rejects, and a mounted Rack app returning a frozen headers Hash,
which Rack 3 forbids. The first now passes a real env; the second an
unfrozen Hash the spec still checks is not written into.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
ericproulx added a commit that referenced this pull request Sep 18, 2026
`lint!` and Grape.config.lint put Rack::Lint in each endpoint's stack, so
two kinds of response never met it: the 404 the router answers for a path
nothing matches, which no endpoint builds, and a Rack app mounted with
`mount`, which the router calls directly instead of through a stack. That
is how a HEAD request for an unknown path answered with a body (#2958)
while the suite, which lints every API, stayed green.

A linted API now hands its router `lint: true`, and the router answers that
404 through Rack::Lint, request and response alike. A bare Rack app's
endpoint wraps the app in Rack::Lint when its scope is linted.

Wrapping the whole router instead would lint every endpoint's response
twice, and two nested Rack::Lints cannot survive a middleware that calls
`to_ary` on the body -- the inner one closes it, the outer one then
iterates it -- which Rack::ETag does in any Rails app mounting the API; the
Rails integration spec fails that way. So only the two responses that
bypass an endpoint's Lint gain one.

The suite's own lint then flagged two fixtures: `API.call({})`, an env
Rack::Lint rejects, and a mounted Rack app returning a frozen headers Hash,
which Rack 3 forbids. The first now passes a real env; the second an
unfrozen Hash the spec still checks is not written into.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
ericproulx added a commit that referenced this pull request Sep 18, 2026
`lint!` and Grape.config.lint put Rack::Lint in each endpoint's stack, so
two kinds of response never met it: the 404 the router answers for a path
nothing matches, which no endpoint builds, and a Rack app mounted with
`mount`, which the router calls directly instead of through a stack. That
is how a HEAD request for an unknown path answered with a body (#2958)
while the suite, which lints every API, stayed green.

A linted API now hands its router `lint: true`, and the router answers that
404 through Rack::Lint, request and response alike. A bare Rack app's
endpoint wraps the app in Rack::Lint when its scope is linted.

Wrapping the whole router instead would lint every endpoint's response
twice, and two nested Rack::Lints cannot survive a middleware that calls
`to_ary` on the body -- the inner one closes it, the outer one then
iterates it -- which Rack::ETag does in any Rails app mounting the API; the
Rails integration spec fails that way. So only the two responses that
bypass an endpoint's Lint gain one.

The suite's own lint then flagged two fixtures: `API.call({})`, an env
Rack::Lint rejects, and a mounted Rack app returning a frozen headers Hash,
which Rack 3 forbids. The first now passes a real env; the second an
unfrozen Hash the spec still checks is not written into.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@ericproulx
ericproulx added this pull request to stack #2962 September 18, 2026 20:19
ericproulx added a commit that referenced this pull request Sep 18, 2026
`lint!` and Grape.config.lint put Rack::Lint in each endpoint's stack, so
two kinds of response never met it: the 404 the router answers for a path
nothing matches, which no endpoint builds, and a Rack app mounted with
`mount`, which the router calls directly instead of through a stack. That
is how a HEAD request for an unknown path answered with a body (#2958)
while the suite, which lints every API, stayed green.

The per-endpoint Rack::Lint is gone. A linted API instead wraps its router
in a single Rack::Lint, which checks every response the API gives --
endpoints, errors, the router's own 404, mounted Rack apps -- exactly once,
and after Rack::Head, so a HEAD response is checked with its body stripped.
Keeping the endpoint Lints and adding one around the router would nest
two, and nested Rack::Lints cannot survive a middleware that calls `to_ary`
on the body -- the inner one closes it, the outer one then iterates it --
which Rack::ETag does in any Rails app mounting the API.

`lint!` is therefore read from the top of the API that is served, as the
README describes it ("at the API level"). Declared inside a namespace, or
in an API mounted into another one, it no longer does anything; README and
UPGRADING say so.

The suite's own lint then flagged two fixtures: `API.call({})`, an env
Rack::Lint rejects, and a mounted Rack app returning a frozen headers Hash,
which Rack 3 forbids. The first now passes a real env; the second an
unfrozen Hash the spec still checks is not written into.

Co-authored-by: Claude Opus 5 <noreply@anthropic.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.

2 participants