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) and pattern += re.escape(part) (:459); self._formatter = formatter (:468).
DynamicResource.canonical returns that encoded formatter (:471-472).
_get_resource_index_key derives the index key from the encoded canonical (:1110-1119): for /hello%20world/{name} the key is /hello%20world.
resolve walks 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 -> 404
Expected 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.12
aiohttp 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.1
propcache Version
$ python -m pip show propcache
Version: 0.5.2
yarl Version
$ python -m pip show yarl
Version: 1.24.5
OS
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
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. ButUrlDispatcher.resolveselects candidates by walking the decodedrequest.rel_url.path_safebackwards. 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/johndecodes to/hello world/john, and the backward walk probes/hello world/john,/hello world,/— never/hello%20world— so it 404s. The same applies toStaticResourceprefixes (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
Output:
Expected behavior
A dynamic route registered as
/hello world/{name}must serve requests to the encoded URL/hello%20world/{name}(that is whaturl_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
aiohttp Version
multidict Version
propcache Version
yarl Version
OS
Linux
Related component
Server
Additional context
The mismatch is between the encoded canonical/index key (registration side) and the decoded
path_safetraversal (resolution side). Direction for a fix:resolve/_get_resource_index_keymust agree on one representation. Since the router already matches on the decoded path everywhere else (Resource.resolve,StaticResource.resolve, and thepath_safewalk), 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