Skip to content

[fix] Resolve API trailing slashes in place instead of redirecting - #5487

Merged
bekossy merged 6 commits into
release/v0.106.1from
fix/api-prefix-redirect-escape
Jul 29, 2026
Merged

[fix] Resolve API trailing slashes in place instead of redirecting#5487
bekossy merged 6 commits into
release/v0.106.1from
fix/api-prefix-redirect-escape

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Jul 24, 2026

Copy link
Copy Markdown
Member

Context

Behind a path-prefixed proxy, an API request that omits its trailing slash never reaches the API. It gets bounced into the web UI and comes back as an HTML 404. Captured in a real browser against a self-hosted deployment:

GET /api/workflows/catalog/harnesses        (no trailing slash)
307  ->  /workflows/catalog/harnesses/      (Starlette; the /api prefix is gone)
308  ->  /workflows/catalog/harnesses       (Next.js normalizing the slash away)
404      HTML error page from the web UI

ApiPrefixStripMiddleware rewrites scope["path"] before routing, so Starlette builds its redirect_slashes response from the stripped path and the prefix disappears from the Location.

The 308 in that chain is the expensive part. Chrome caches it permanently, so from then on the browser rewrites the slashed URL to the unslashed one before it hits the network. Every subsequent correct request from the app gets rewritten into a broken one, and the escape mints the 308 again, so the cache re-poisons itself. On the affected deployment this silently degraded the playground's model drawer to its pre-catalog layout, which reads as a stale frontend build. It cost hours to trace.

Some deployments carry hand-written no-slash route aliases for the tools catalog. They are the same bug, patched one route at a time.

Changes

The middleware now resolves the trailing slash against the route table on the way in, and redirect_slashes is disabled in the composition root. Both URL shapes route directly to the handler and no Location header is emitted at all.

Before:  GET /api/<route>   ->  307 Location: /<route>/   (escapes the API)
After:   GET /api/<route>   ->  200                       (no redirect)

Correcting the Location instead was the obvious fix and it is wrong. Any client already holding that cached 308 gets a loop: its cache sends slashed to unslashed, the corrected redirect sends unslashed back to slashed. That was tried first and produced ERR_TOO_MANY_REDIRECTS on a live deployment. Resolving the path in place removes the escape and the loop together, and it repairs already-poisoned clients without asking them to clear anything, because the URL their cache produces now returns 200.

A method mismatch still answers 405 rather than being hidden behind a slash rewrite, and unknown paths still 404.

Tests / notes

Deployed to an isolated stack (separate compose project, own database, port 8390) behind the same Traefik and gunicorn/uvicorn config, and driven with headless Chrome rather than curl. curl cannot see this class of bug: it has no redirect cache.

Browser, unslashed URL, patched vs unpatched:

PATCHED     200, 0 redirects, API JSON, never touches the web UI
UNPATCHED   307 -> 308 -> 404 HTML, lands in the web UI

Regression sweep on the patched stack, all passing: /api/health, both slash forms of the harness catalog, the double-prefixed /api/api/... path, both slash forms of a path-param route, POST to a GET route returning 405, an unknown path returning 404, and the tools catalog without its trailing slash. The API emitted zero 3xx responses across the whole sweep. No import or startup errors.

Two things for a reviewer to weigh:

  • redirect_slashes is disabled app-wide. Route matching now covers what it did, minus the redirect, so a path that previously redirected resolves directly. A path neither form matches still 404s, as before.
  • The middleware matches against the route table only when the first form misses, so requests that already carry the right shape pay nothing.

Not addressed here: the frontend degrades silently when a catalog request fails, which is what made this hard to diagnose. That is #5492.

What to QA

  • On a deployment served under a path prefix, curl any API route without its trailing slash, for example /api/workflows/catalog/harnesses. It returns JSON with no redirect, rather than an HTML page.
  • Open the playground and configure an agent variant. The Model & harness drawer shows the Harness, Model, and Provider credentials sections, not a single-column dropdown.
  • Regression: sign in with a social or SSO provider. The SuperTokens callback still works.
  • Regression: call a GET-only route with POST. It still returns 405, not 404.

…b app

ApiPrefixStripMiddleware rewrites scope["path"] before routing, so Starlette
builds its trailing-slash redirect from the stripped path. Behind a path-prefixed
proxy the 307 for /api/<route> (no trailing slash) points at /<route>/, which is
served by the web UI — the caller gets an HTML 404 instead of the API response.

Re-prefix 307/308 Locations on the way out, only when this middleware actually
stripped, and only for root-relative paths so off-host redirects (SuperTokens
callback) are untouched.
@dosubot dosubot Bot added the size:M This PR changes 30-99 lines, ignoring generated files. label Jul 24, 2026
@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview, Comment Jul 29, 2026 12:47pm

Request Review

@dosubot dosubot Bot added the Backend label Jul 24, 2026
@CLAassistant

CLAassistant commented Jul 24, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ bekossy
❌ Agenta Team


Agenta Team seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 25f5fc7f-ffce-4087-a575-2fcb1f24b16d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The API prefix middleware now strips repeated /api prefixes and normalizes trailing slashes using route matches for HTTP and WebSocket requests. Router slash redirects are disabled, and the middleware receives routes through a deferred provider.

Changes

API Prefix and Trailing-Slash Normalization

Layer / File(s) Summary
Prefix and slash normalization
api/oss/src/middlewares/prefix.py
Defines API_PREFIX, strips repeated prefixes from paths and raw paths, and selects a route-known trailing-slash variant.
Router route wiring
api/entrypoints/routers.py
Disables router trailing-slash redirects and supplies the initial app’s routes to the middleware through a deferred provider.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ApiPrefixStripMiddleware
  participant Routes
  participant Application
  Client->>ApiPrefixStripMiddleware: HTTP or WebSocket request
  ApiPrefixStripMiddleware->>ApiPrefixStripMiddleware: Strip repeated /api prefixes
  ApiPrefixStripMiddleware->>Routes: Check exact and slash-variant paths
  Routes-->>ApiPrefixStripMiddleware: Return route match result
  ApiPrefixStripMiddleware->>Application: Forward normalized scope
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly describes the main change: handling API trailing slashes in place instead of redirecting.
Description check ✅ Passed The description is directly about the same trailing-slash and redirect behavior changed in the PR.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/api-prefix-redirect-escape

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
api/oss/src/middlewares/prefix.py (1)

33-40: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use API_PREFIX for raw-path rewriting too.

The path logic uses API_PREFIX, but Lines [37-39] still hardcode b"/api" and its length. If the shared constant changes, scope["path"] and scope["raw_path"] can diverge. Derive the byte prefix once and use it for both checks and slicing.

Proposed fix
 API_PREFIX = "/api"
+_API_PREFIX_BYTES = API_PREFIX.encode("ascii")

-                    raw[: len(API_PREFIX)] == b"/api"
+                    raw[: len(_API_PREFIX_BYTES)] == _API_PREFIX_BYTES
...
-                    raw = bytes(raw)[len(API_PREFIX) :] or b"/"
+                    raw = bytes(raw)[len(_API_PREFIX_BYTES) :] or b"/"

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 97ca8bb4-9813-4f3f-80b8-879bbf0b6bc1

📥 Commits

Reviewing files that changed from the base of the PR and between cb095f7 and 17beab0.

📒 Files selected for processing (1)
  • api/oss/src/middlewares/prefix.py

Comment thread api/oss/src/middlewares/prefix.py Outdated
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Status Destroyed (PR closed)

Updated at 2026-07-29T12:48:49.989Z

@mmabrouk

Copy link
Copy Markdown
Member Author

Marking this draft. Verified in a real browser (fresh profile) that the fix behaves correctly: unslashed does one hop to slashed and returns 200. But deploying it to a self-hosted box surfaced a failure mode I missed.

If a client already holds a cached 308 that rewrites the slashed URL back to the unslashed one, this fix closes the circle into ERR_TOO_MANY_REDIRECTS: cache sends slashed -> unslashed, this redirect sends unslashed -> slashed, forever. Before the fix that same client got a silent HTML 404, which is broken but not a loop.

Trading a silent 404 for a redirect loop is not an improvement. The better shape is to stop redirecting at all on these routes: answer the unslashed path directly (internal rewrite, no Location header). That fixes poisoned clients without them clearing anything, and removes the loop by construction. Reworking along those lines.

Correcting the redirect's Location was the wrong shape. A client holding a
cached slash-normalizing 308 gets a redirect loop: its cache rewrites slashed
to unslashed, the corrected Location sends unslashed back to slashed. Observed
on a self-hosted deployment as ERR_TOO_MANY_REDIRECTS.

Match the trailing slash against the route table on the way in and disable
redirect_slashes, so both shapes resolve directly and no Location is ever
emitted. That removes the escape and the loop together.
@mmabrouk mmabrouk changed the title [fix] Keep /api on slash redirects so they don't escape to the web UI [fix] Resolve API trailing slashes in place instead of redirecting Jul 24, 2026
@mmabrouk

Copy link
Copy Markdown
Member Author

Reworked. The earlier approach (correcting the redirect's Location) is replaced: it produced ERR_TOO_MANY_REDIRECTS against a client holding a cached slash-normalizing 308, which is worse than the silent 404 it replaced. This version resolves the trailing slash against the route table on the way in and disables redirect_slashes, so no Location is emitted at all and already-poisoned clients recover without clearing anything. Verified in headless Chrome against an isolated stack, not curl. Description rewritten.

@mmabrouk
mmabrouk marked this pull request as ready for review July 24, 2026 22:55
@dosubot dosubot Bot added the Bug Report Something isn't working label Jul 24, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d43165e6-10ea-4780-a38f-e5678da19e0f

📥 Commits

Reviewing files that changed from the base of the PR and between 17beab0 and c30b18c.

📒 Files selected for processing (2)
  • api/entrypoints/routers.py
  • api/oss/src/middlewares/prefix.py

Comment thread api/oss/src/middlewares/prefix.py Outdated
@bekossy
bekossy changed the base branch from main to release/v0.106.1 July 29, 2026 12:08
The prefix strip and the trailing-slash alternate-form lookup both
mutated `path` but tried to mirror that onto `raw_path` with fragile
byte-level heuristics: a literal `b"/api"` check that a percent-encoded
prefix (`/%61pi/...`) never matches, and an ascii-encode("ignore") that
silently drops non-ASCII bytes. Either case left raw_path stale or
corrupted relative to the routed path.

Re-derive raw_path from the final normalized path instead of tracking
it in parallel, so the two can never diverge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label Jul 29, 2026
@bekossy
bekossy merged commit 19faeb2 into release/v0.106.1 Jul 29, 2026
32 of 33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend Bug Report Something isn't working lgtm This PR has been approved by a maintainer size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants