Skip to content

Let a cascading route hand over to every remaining route - #2829

Merged
ericproulx merged 1 commit into
masterfrom
fix-router-cascade-sibling-routes
Jul 27, 2026
Merged

Let a cascading route hand over to every remaining route#2829
ericproulx merged 1 commit into
masterfrom
fix-router-cascade-sibling-routes

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

Problem

A route that answers with X-Cascade: pass is declining the request, asking the router to try the next matching route. Grape::Router#transaction did not do that. When the matched route cascaded and an ANY ('*') route existed, it took a shortcut:

return last_neighbor_route.call(env) if last_neighbor_route && last_response_cascade && route

last_neighbor_route is the greedy route — built per path pattern from routes.last (see API::Instance#collect_route_config_per_pattern), i.e. the last route registered for that path. So a cascading match handed over to exactly one route, the last one, and returned its answer as final. Every route registered in between was unreachable.

With two mounted versions this looks correct — "the last route" is the only sibling — which is why it survived a decade (the branch dates to the 2016 router rewrite, 499f6c6f, "Modify the priority of ANY routes, fixes #1089") and why the existing with catch-all shared example, which mounts exactly v1 and v2, passes.

Add a third version and the middle one disappears:

v1, v2, v3 = %w[v1 v2 v3].map { |v| versioned_api(v) }   # header versioning, cascade: true (default)

Class.new(Grape::API) do
  format :txt
  mount v1
  mount v2
  mount v3
  route(:any, '*path') { "catch-all: #{params[:path]}" }
end
request before after
Accept: …-v1+txt 200 v1 endpoint 200 v1 endpoint
Accept: …-v2+txt 406 API version not found 200 v2 endpoint
Accept: …-v3+txt 200 v3 endpoint 200 v3 endpoint
Accept: …-v999+txt 406 API version not found 200 catch-all: version

v1 works because it is matched first; v3 works because it is the greedy route; v2 is served by neither.

Fix

When a match cascades, try the method's remaining routes (#rotation) before falling back to the ANY routes and the greedy neighbour. The rest of the priority order — auto-OPTIONS, ANY routes, the greedy 405 — is unchanged, and #rotation is skipped entirely when nothing matched, since the compiled union it consults is the disjunction of the very patterns #rotation would walk.

#identity disappears (its two halves are now the first lines of #transaction), and Router#call no longer needs to run #rotation itself — the reason a request could previously reach #rotation only after the ANY route had already answered.

Behavior change: an unmatched version reaches a catch-all

Beyond the middle-version fix, one deliberate consequence, noted in UPGRADING: when a catch-all ANY route is present, a request whose version matches nothing now falls through to it instead of surfacing the versioner's 406 (last row of the table). That is what cascade: true — the default — asks for, and it is the same rule that already routes an unversioned path to the catch-all. version ..., cascade: false still answers 406 immediately and never consults the catch-all, so the hard-failure knob is unchanged.

Cascading bodies are now closed

#rotation discarded superseded cascading responses without closing their bodies. That was unreachable-ish before (rotation ran last); as soon as a third sibling made the path live, Rack::Lint flagged Body has not been closed. Superseded responses are now released through a shared #close_body, which #halt? also uses.

Verification

Beyond the suite, I diffed a 47-scenario probe of router behavior (versioned/unversioned, 1–3 versions, with and without a catch-all, cascade: false, path/header versioning, auto-OPTIONS, 405, HEAD, ANY-only, mixed specific+ANY, bare Rack mounts, nested mounts, 404s) between master and this branch. Exactly the four rows in the table above changed; the other 43 are byte-identical.

New specs: the shared versioning examples' with catch-all context gains a third version — so the existing v2 cases become middle-version cases across all four versioning strategies — plus an unknown-version case; router_spec.rb gains router-level specs pinning the contract without going through versioning. All new specs fail on master.

Relationship to #2824

#2824 fixes the routing-args leak on this same path (a cascaded-to route inheriting the previous attempt's route_info and path captures). They are independent, but complementary: this PR makes middle versions reachable, and #2824 is what makes the route that serves them see its own route_info and params. Both touch lib/grape/router.rb and spec/grape/router_spec.rb, and both add an UPGRADING entry, so whichever merges second needs a trivial rebase.

🤖 Generated with Claude Code

@ericproulx
ericproulx force-pushed the fix-router-cascade-sibling-routes branch from 7278253 to a3224f0 Compare July 26, 2026 20:01
@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@ericproulx
ericproulx requested a review from dblock July 26, 2026 20:04
@ericproulx
ericproulx force-pushed the fix-router-cascade-sibling-routes branch from a3224f0 to 5401b58 Compare July 27, 2026 05:12
Router#transaction sent a cascading match straight to the greedy
neighbour — the last route registered for the path — and returned its
answer, so any route registered between the first match and that last
one was unreachable. With three mounted API versions plus a catch-all
`route :any, '*path'`, v1 and v3 were served while v2 answered 406; the
shortcut only ever looked correct with exactly two versions, where "the
last route" happens to be the sibling.

Try the method's remaining routes (#rotation) as soon as a match
cascades, before the ANY routes and the greedy neighbour, and keep the
priority order otherwise intact. A consequence, and what cascade: true
means: with a catch-all present, an unmatched version now falls through
to it rather than surfacing the versioner's 406. cascade: false still
answers 406 immediately and never consults the catch-all.

Also close the body of every superseded cascading response — Rack
requires it, and rotation previously leaked them (Rack::Lint flagged
this as soon as a third sibling made the path reachable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ericproulx
ericproulx force-pushed the fix-router-cascade-sibling-routes branch from 5401b58 to a9e5cec Compare July 27, 2026 05:28
@ericproulx
ericproulx merged commit 59188e2 into master Jul 27, 2026
69 checks passed
@ericproulx
ericproulx deleted the fix-router-cascade-sibling-routes branch July 27, 2026 05:38
ericproulx added a commit that referenced this pull request Jul 27, 2026
#2824 made process_route build a fresh routing-args Hash per attempt, so
a candidate tried after a cascading match (X-Cascade pass) could not
observe the previous attempt's :route_info or path captures. #2829 was
squash-merged from a branch cut before #2824 landed, so its squashed diff
replayed the stale router.rb over the fix and dropped the regression spec
along with it. Git reported no conflict, and #2824's CHANGELOG entry
survived, so the tree claims a fix it no longer has.

Symptoms are back on master: with a cascading v2 and a serving v1, the
`route` helper returns v2's route (origin "/:id" instead of "/:name") and
v2's capture leaks into params as {"id"=>"123", "name"=>"123"} -- an
endpoint sees a param it never declared.

Restore the fix and its spec verbatim. The router is the only writer of
env['grape.routing_args'], so the unconditional assignment is safe.

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