From ec85469befc17d035f90f275f263e39218bd30b9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 9 Jul 2026 16:55:37 +0530 Subject: [PATCH] feat: implement route caching for static paths in APIRouter to improve routing performance --- fastapi/routing.py | 70 ++++++++++++++++++++++++++++++++--- tests/test_route_caching.py | 74 +++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 tests/test_route_caching.py diff --git a/fastapi/routing.py b/fastapi/routing.py index e41ef6a59..7b65fef73 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -2384,9 +2384,14 @@ class APIRouter(routing.Router): self._routes_version = 0 self._low_priority_routes: list[BaseRoute] = [] self._frontend_routes: _FrontendRouteGroup | None = None + self._route_cache: dict[ + tuple[str, str | None, str], + tuple[Match, BaseRoute, dict[str, Any], Any | None], + ] = {} def _mark_routes_changed(self) -> None: self._routes_version += 1 + self._route_cache.clear() def _get_routes_version(self, seen: set[int] | None = None) -> int: if seen is None: @@ -2535,20 +2540,73 @@ class APIRouter(routing.Router): await self.lifespan(scope, receive, send) return - partial: tuple[BaseRoute, Scope] | None = None + # Check cache + cache_key = (scope["type"], scope.get("method"), scope["path"]) + cached = self._route_cache.get(cache_key) + if cached is not None: + match_type, leaf_route, child_scope, effective_context = cached + if match_type in (Match.FULL, Match.PARTIAL): + resolved_scope = dict(child_scope) + if "path_params" in resolved_scope: + resolved_scope["path_params"] = dict(resolved_scope["path_params"]) + scope.update(resolved_scope) + if effective_context is not None: + _get_fastapi_scope(scope)[_FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY] = effective_context + scope["route"] = effective_context.original_route + else: + scope["route"] = leaf_route + await leaf_route.handle(scope, receive, send) + return + + partial: tuple[BaseRoute, Scope, BaseRoute, Any] | None = None for route in self.routes: - match, child_scope = route.matches(scope) + if isinstance(route, _IncludedRouter): + match, child_scope, leaf_route, effective_context = route._match(scope) + else: + match, child_scope = route.matches(scope) + leaf_route = route + effective_context = None + if match == Match.FULL: + is_static = False + if isinstance(leaf_route, APIRoute): + is_static = len(leaf_route.dependant.path_params) == 0 + elif isinstance(leaf_route, routing.Route): + is_static = not leaf_route.param_convertors + + if is_static: + self._route_cache[cache_key] = (Match.FULL, leaf_route, child_scope, effective_context) + scope.update(child_scope) - await route.handle(scope, receive, send) + if effective_context is not None: + _get_fastapi_scope(scope)[_FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY] = effective_context + scope["route"] = effective_context.original_route + else: + scope["route"] = leaf_route + await leaf_route.handle(scope, receive, send) return + if match == Match.PARTIAL and partial is None: - partial = (route, child_scope) + partial = (route, child_scope, leaf_route, effective_context) if partial is not None: - route, child_scope = partial + _, child_scope, leaf_route, effective_context = partial + is_static = False + if isinstance(leaf_route, APIRoute): + is_static = len(leaf_route.dependant.path_params) == 0 + elif isinstance(leaf_route, routing.Route): + is_static = not leaf_route.param_convertors + + if is_static: + self._route_cache[cache_key] = (Match.PARTIAL, leaf_route, child_scope, effective_context) + scope.update(child_scope) - await route.handle(scope, receive, send) + if effective_context is not None: + _get_fastapi_scope(scope)[_FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY] = effective_context + scope["route"] = effective_context.original_route + else: + scope["route"] = leaf_route + await leaf_route.handle(scope, receive, send) return route_path = get_route_path(scope) diff --git a/tests/test_route_caching.py b/tests/test_route_caching.py new file mode 100644 index 000000000..6b7a76093 --- /dev/null +++ b/tests/test_route_caching.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter, FastAPI +from fastapi.testclient import TestClient + + +def test_static_route_caching(): + app = FastAPI() + router = APIRouter() + + @router.get("/static") + def read_static(): + return {"status": "ok"} + + @router.get("/users/{user_id}") + def read_user(user_id: int): + return {"user_id": user_id} + + app.include_router(router) + client = TestClient(app) + + # First request: Cache miss, resolves normally + response = client.get("/static") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + # Check cache size (should be 1) + cache = app.router._route_cache + assert len(cache) == 1 + cache_key = ("http", "GET", "/static") + assert cache_key in cache + + # Second request: Cache hit, resolves via cache + response = client.get("/static") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + assert len(cache) == 1 + + # Dynamic request: matches but should not be cached + response = client.get("/users/123") + assert response.status_code == 200 + assert response.json() == {"user_id": 123} + assert cache_key in cache + # Verify dynamic path is not added to the cache + assert len(cache) == 1 + assert ("http", "GET", "/users/123") not in cache + + +def test_cache_invalidation(): + app = FastAPI() + router = APIRouter() + + @router.get("/health") + def health(): + return {"status": "healthy"} + + app.include_router(router) + client = TestClient(app) + + # First request + response = client.get("/health") + assert response.status_code == 200 + assert len(app.router._route_cache) == 1 + + # Invalidate by adding a new route dynamically + @app.get("/new-route") + def new_route(): + return {"new": "yes"} + + # Cache should be cleared + assert len(app.router._route_cache) == 0 + + # Request new route and health route to populate cache again + client.get("/new-route") + client.get("/health") + assert len(app.router._route_cache) == 2