Skip to content

Answer 404 when every route on a path cascades - #3043

Merged
ericproulx merged 1 commit into
masterfrom
fix/cascading-routes-answer-404
Oct 31, 2026
Merged

ericproulx merged 1 commit into
masterfrom
fix/cascading-routes-answer-404

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

Summary

A route declines a request with X-Cascade: pass, as a version declared with cascade: true does for a version it doesn't serve. The README documents what follows: "a 406 Not Acceptable error is returned if the :cascade option is set to false. Otherwise a 404 Not Found error is returned by Rack if no other route matches."

The router only did that when exactly one route had declined:

GET /x with an unknown version Before After
One version, cascade: true 404 404
Two or more versions, cascade: true 406 404
route :any, cascade: true 406 (404 before #2829) 404
Last version cascade: false 406 406
One version, cascade: false 406 406

The number of versions shouldn't change the status. It also breaks Rack::Cascade, which goes by the status alone: the 406 stopped it short of the next app, despite the X-Cascade: pass header.

A declined response is now always closed and dropped, leaving the router's own 404, which still carries X-Cascade. As a side effect the router no longer returns a cascading ANY route's response with its body already closed. An ANY route that declined still matched, so no 405 follows it, as before.

Contract change

The two-or-more-versions 406 is long-standing (it's there in v3.3.5, the oldest release I ran), so clients may rely on it. Hence the UPGRADING entry, including how to keep the 406: cascade: false on the last version only. On an earlier version it makes the later versions unreachable.

The status, headers and body of a declined response are dropped in every case now, where before they survived only with two or more routes on the path.

Test plan

  • New specs fail on master for each row that changed, and for Rack::Cascade.
  • Mutation-checked: handing back a cascading sibling, handing back a cascading ANY route, and falling through to the 405 after a cascading ANY route are each caught.
  • bundle exec rake: RuboCop clean, 3167 examples, 0 failures.
  • CI green.

🤖 Generated with Claude Code

A route declines a request with X-Cascade: pass. The README documents the
result for an invalid version under `cascade: true`: a 404 if no other route
matches. The router only did that when one route had declined. With two or
more routes on the path, #rotation handed back the last one's response, and
since #2829 a cascading ANY route's response came back too, with its body
already closed. So an unknown version answered 404 from an API with one
version and 406 from an API with several.

Rack::Cascade goes by the status alone, so that 406 also stopped it short of
the next app, despite the X-Cascade header.

A declined response is now always closed and dropped, leaving the router's own
404, which still carries X-Cascade. An ANY route that declined still matched,
so no 405 follows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ericproulx
ericproulx force-pushed the fix/cascading-routes-answer-404 branch from 834d683 to 126f705 Compare October 31, 2026 14:42
@github-actions

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@ericproulx
ericproulx merged commit 575ab89 into master Oct 31, 2026
69 checks passed
ericproulx added a commit that referenced this pull request Oct 31, 2026
#settle and #neighbours handled two situations, no route matching and a route
declining, and told them apart with a `cascaded` flag. That flag was true
exactly when a route had matched, so it is replaced by two paths: #after_miss
looks for the allowed methods and the ANY routes, #after_cascade tries the
siblings and the ANY routes. #halt?, a predicate that closed a body as a side
effect, goes with it.

The names said something else, so:

- `transaction` is `dispatch`: there is no atomicity or rollback in it.
- `match?`, `greedy_match?` and `neighbour_match?` return a route, so they are
  `star_route_for`, `allowed_methods_route_for` and `neighbour_route_for`.
- `GreedyRoute` is `AllowedMethodsRoute`: it matches on the path whatever the
  method, and only ever answers with the methods the path allows. Nothing in
  the ruby-grape org uses the constant outside this repo.

The two lookups that repeated `routes.detect` go through `matched_route`.

This is #3044, which merged into #3043's branch after #3043 had already merged,
so it never reached master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ericproulx added a commit that referenced this pull request Oct 31, 2026
#settle and #neighbours handled two situations, no route matching and a route
declining, and told them apart with a `cascaded` flag. That flag was true
exactly when a route had matched, so it is replaced by two paths: #after_miss
looks for the allowed methods and the ANY routes, #after_cascade tries the
siblings and the ANY routes. #halt?, a predicate that closed a body as a side
effect, goes with it.

The names said something else, so:

- `transaction` is `dispatch`: there is no atomicity or rollback in it.
- `match?`, `greedy_match?` and `neighbour_match?` return a route, so they are
  `star_route_for`, `allowed_methods_route_for` and `neighbour_route_for`.
- `GreedyRoute` is `AllowedMethodsRoute`: it matches on the path whatever the
  method, and only ever answers with the methods the path allows. Nothing in
  the ruby-grape org uses the constant outside this repo.

The two lookups that repeated `routes.detect` go through `matched_route`.

This is #3044, which merged into #3043's branch after #3043 had already merged,
so it never reached master.

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

1 participant