Answer a HEAD request for a path no route matches without a body - #2958
Open
ericproulx wants to merge 1 commit into
Open
ericproulx wants to merge 1 commit into
ericproulx wants to merge 1 commit into
Conversation
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
force-pushed
the
fix/router-head-404-body
branch
from
September 18, 2026 14:35
9917f42 to
627bb32
Compare
Danger ReportNo issues found. |
5 tasks
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
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>
dblock
approved these changes
Sep 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Rack requires an empty body in a response to a HEAD request. Every endpoint strips its own body with the
Rack::Headat the top of its stack. A path that nothing matches, though, is answered byRouter#default_response, which no endpoint builds. So a HEAD request for such a path gets404 Not Foundback as its body, andRack::Lintraises on it:rackupaddsRack::Lintin development, so a HEAD request for an unknown path fails there instead of returning a 404. The same happens on 3.3.5.lint!andGrape.config.lint(which the suite turns on inspec_helper) addRack::Lintto each endpoint's stack only, so they never see this response. Wrapping the whole API inRack::Lintdoes. Running the suite that way shows this is the only Rack SPEC violation Grape produces: the two anchoring HEAD examples inendpoint_specfail, and nothing else does. A follow-up PR makeslint!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
router_specexample (HEAD body empty, GET body unchanged) fails on master and passes here.Rack::Lint(only thelint!spec that expects its error logs one).🤖 Generated with Claude Code