Skip to content

ASGI integration doubles the mount prefix in request.url for Starlette Mounted sub-apps (root_path + path) #6577

@pencil

Description

@pencil

Summary

_get_url() in sentry_sdk/integrations/_asgi_common.py builds the request URL as root_path + path:

path = asgi_scope.get("root_path", "") + asgi_scope.get("path", "")

Under the current ASGI spec, scope["path"] already includes scope["root_path"]. So for any Starlette Mount(...)ed sub-app the mount prefix is counted twice — a request to /mcp/ is reported as /mcp/mcp/. This affects request.url on both error and transaction events for every sub-mounted ASGI app.

Version / environment

  • sentry-sdk: 2.59.0 (also reproduces on master_get_url is unchanged there)
  • starlette: 1.0.1
  • uvicorn: 0.38.0
  • python: 3.14.5

Minimal reproduction

from sentry_sdk.integrations._asgi_common import _get_url

# The scope a Starlette Mount("/mcp", sub_app) hands its sub-app when a client
# requests "/mcp/" — path includes root_path, per the current ASGI spec.
scope = {
    "type": "http", "scheme": "http",
    "server": ("api.example.com", 80),
    "headers": [(b"host", b"api.example.com")],
    "root_path": "/mcp",
    "path": "/mcp/",
}
print(_get_url(scope, "http", "api.example.com"))
# ->  http://api.example.com/mcp/mcp/        (expected: http://api.example.com/mcp/)

Confirming a real Mount produces exactly that scope:

from starlette.applications import Starlette
from starlette.routing import Mount, Route
from starlette.responses import PlainTextResponse
from starlette.testclient import TestClient

seen = {}
async def ep(request):
    seen.update(path=request.scope["path"], root_path=request.scope["root_path"])
    return PlainTextResponse("ok")

app = Starlette(routes=[Mount("/mcp", app=Starlette(routes=[Route("/", endpoint=ep)]))])
TestClient(app).get("/mcp/")
print(seen)   # {'path': '/mcp/', 'root_path': '/mcp'}

Root cause

The ASGI spec states that scope["path"] contains the full path including root_path (see starlette#1336 and uvicorn#2213). _get_url predates that change and assumes the WSGI-style disjoint SCRIPT_NAME + PATH_INFO split, so it double-counts whenever root_path is non-empty — which Starlette sets for every Mount.

Relationship to prior reports

  • FastAPI integration ROOT_PATH problem #1832 reported the same root_path + path doubling but was closed inconclusively because it couldn't be reproduced — that repro used FastAPI's root_path= argument, where (pre-uvicorn#2213) the prefix was not folded into path, so the sum happened to be correct. The trigger that actually doubles is a Starlette Mount, shown above.
  • Support hosted/mounted sub applications on FastAPI #2631 (mounted sub-apps) was closed as completed, but request.url doubling persists — _get_url still sums the two.

Suggested fix

Only prepend root_path when path doesn't already include it:

path = asgi_scope.get("path", "")
root_path = asgi_scope.get("root_path", "")
if root_path and not (path == root_path or path.startswith(root_path + "/")):
    path = root_path + path

This preserves the old behavior for servers that still pass a disjoint root_path / path, while not double-counting for spec-compliant ones.

Metadata

Metadata

Assignees

No one assigned
    No fields configured for issues without a type.

    Projects

    Status
    Waiting for: Product Owner

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions