Skip to content

Dynamic routes with percent-encodable fixed segments (space, non-ASCII) are unreachable and url_for() URLs 404 #13433

Description

@Yanhaoxi

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

  • I agree to follow the aio-libs Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions