Dynamic routes with percent-encodable fixed segments (space, non-ASCII) are unreachable and url_for() URLs 404
Describe the bug
Dynamic routes (and static/prefix resources) whose fixed path segment contains characters that need percent-encoding — a space, non-ASCII, or some reserved characters — are unreachable: every request to them returns 404, and the URL produced by url_for() cannot be routed back either.
The fixed part is percent-encoded once at registration (_requote_path) and the encoded form is used both for the resource-index key and the match pattern. But UrlDispatcher.resolve selects candidates by walking the decoded request.rel_url.path_safe backwards. The encoded index key is therefore never probed, so the resource is never a candidate.
Example: app.router.add_get("/hello world/{name}", handler, name="greet") compiles to formatter /hello%20world/{name} and index key /hello%20world. A request for /hello%20world/john decodes to /hello world/john, and the backward walk probes /hello world/john, /hello world, / — never /hello%20world — so it 404s. The same applies to StaticResource prefixes (app.router.add_static("/static files/", ...) is unreachable over /static%20files/...).
Verified on aiohttp 3.14.3 and on master.
Relevant code (aiohttp 3.14.3, aiohttp/web_urldispatcher.py):
DynamicResource.__init__encodes fixed parts and uses the encoded form for both formatter and pattern literal:part = _requote_path(part)(:457) andpattern += re.escape(part)(:459);self._formatter = formatter(:468).DynamicResource.canonicalreturns that encoded formatter (:471-472)._get_resource_index_keyderives the index key from the encoded canonical (:1110-1119): for/hello%20world/{name}the key is/hello%20world.resolvewalks the decoded path backwards (url_part = request.rel_url.path_safe:1033; loop :1034-1043) and 404s when no candidate matches (:1048).
To Reproduce
import asyncio
from aiohttp import web
async def main() -> None:
app = web.Application()
# dynamic route with a percent-encodable fixed segment (space)
app.router.add_get("/hello world/{name}", lambda r: web.Response(text=r.match_info["name"]), name="greet")
# plain route with the same fixed segment (control: works)
app.router.add_get("/hello world/", lambda r: web.Response(text="plain"))
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 8080)
await site.start()
async def get(path: str) -> None:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(f"http://127.0.0.1:8080{path}") as resp:
print(f"GET {path:24} -> {resp.status}")
await get("/hello%20world/") # plain route -> 200
await get("/hello%20world/john") # dynamic route -> 404 (expected 200)
await get(str(app.router["greet"].url_for(name="john"))) # url_for -> 404
await runner.cleanup()
asyncio.run(main())Output:
GET /hello%20world/ -> 200
GET /hello%20world/john -> 404
GET /hello%20world/john -> 404Expected behavior
A dynamic route registered as /hello world/{name} must serve requests to the encoded URL /hello%20world/{name} (that is what url_for() produces), matching the decoded-path semantics the router uses everywhere else. The plain route with the same fixed segment resolves fine, so such characters are clearly supported — only the dynamic/static registration path is broken.
Logs/tracebacks
No traceback; the requests just return 404. Full self-checking repro with controls (also covers `StaticResource`):
PASS - c1: plain route '/hello world/' resolves over %20
PASS - c2: dynamic route without encodable chars resolves
FAIL - a3: StaticResource over '/static files/' resolves -> 200 (got 404)
FAIL - a1: dynamic '/hello world/{name}' matches -> 200 (got 404)
FAIL - a2: url_for-generated URL resolves back -> 200 (got 404)Python Version
$ python --version
Python 3.10.12aiohttp Version
$ python -m pip show aiohttp
Name: aiohttp
Version: 3.14.3 (also reproduced on master @ 356d355b7)multidict Version
$ python -m pip show multidict
Version: 6.7.1propcache Version
$ python -m pip show propcache
Version: 0.5.2yarl Version
$ python -m pip show yarl
Version: 1.24.5OS
Linux
Related component
Server
Additional context
The mismatch is between the encoded canonical/index key (registration side) and the decoded path_safe traversal (resolution side). Direction for a fix: resolve/_get_resource_index_key must agree on one representation. Since the router already matches on the decoded path everywhere else (Resource.resolve, StaticResource.resolve, and the path_safe walk), the index key should be derived from the decoded path too (e.g. keep the raw fixed segment for the key), or the backward walk should also probe the encoded forms. url_for/resolve round-tripping is the documented contract (docs/web_quickstart.rst).
Code of Conduct
- I agree to follow the aio-libs Code of Conduct
Source: aio-libs/aiohttp