diff --git a/benchmark_routing.py b/benchmark_routing.py new file mode 100644 index 000000000..724c8b430 --- /dev/null +++ b/benchmark_routing.py @@ -0,0 +1,105 @@ +import time + +from fastapi import APIRouter, FastAPI + +# Setup Router +app = FastAPI() +router = APIRouter() + +# 50 Static Routes +for i in range(50): + + @router.get(f"/static-{i}") + def static_route(i=i): + return {"route": f"static-{i}"} + + +# 50 Dynamic Routes +for i in range(50): + + @router.get(f"/dynamic-{i}/{{item_id}}") + def dynamic_route(item_id: int, i=i): + return {"route": f"dynamic-{i}", "item_id": item_id} + + +app.include_router(router) +from contextlib import AsyncExitStack + +import anyio + + +# Mock ASGI stack +async def mock_receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + +async def mock_send(message): + pass + + +scope_static = { + "type": "http", + "method": "GET", + "path": "/static-49", + "headers": [], + "query_string": b"", + "client": ("127.0.0.1", 1234), + "server": ("127.0.0.1", 80), + "fastapi_middleware_astack": AsyncExitStack(), + "fastapi_inner_astack": AsyncExitStack(), + "fastapi_function_astack": AsyncExitStack(), +} + +scope_dynamic = { + "type": "http", + "method": "GET", + "path": "/dynamic-49/12345", + "headers": [], + "query_string": b"", + "client": ("127.0.0.1", 1234), + "server": ("127.0.0.1", 80), + "fastapi_middleware_astack": AsyncExitStack(), + "fastapi_inner_astack": AsyncExitStack(), + "fastapi_function_astack": AsyncExitStack(), +} + + +async def run_benchmark(): + # Warmup + for _ in range(100): + await app.router.app(dict(scope_static), mock_receive, mock_send) + await app.router.app(dict(scope_dynamic), mock_receive, mock_send) + + print("Running Static Route Benchmark (10,000 iterations)...") + + # 1. Caching Enabled (standard flow) + # Warmup cache + await app.router.app(dict(scope_static), mock_receive, mock_send) + start_time = time.perf_counter() + for _ in range(10000): + await app.router.app(dict(scope_static), mock_receive, mock_send) + cache_enabled_time = time.perf_counter() - start_time + + # 2. Caching Disabled (clear cache before each request) + start_time = time.perf_counter() + for _ in range(10000): + app.router._route_cache.clear() + await app.router.app(dict(scope_static), mock_receive, mock_send) + cache_disabled_time = time.perf_counter() - start_time + + print(f"Static Route - Cache Enabled: {cache_enabled_time:.4f} seconds") + print(f"Static Route - Cache Disabled: {cache_disabled_time:.4f} seconds") + print(f"Speedup: {cache_disabled_time / cache_enabled_time:.2f}x") + + print("\nRunning Dynamic Route Benchmark (10,000 iterations)...") + + # Caching has no effect on dynamic routes + start_time = time.perf_counter() + for _ in range(10000): + await app.router.app(dict(scope_dynamic), mock_receive, mock_send) + dynamic_time = time.perf_counter() - start_time + print(f"Dynamic Route time: {dynamic_time:.4f} seconds") + + +if __name__ == "__main__": + anyio.run(run_benchmark) diff --git a/fastapi/routing.py b/fastapi/routing.py index 28694b4e5..27bf48fe7 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -2510,9 +2510,30 @@ 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], + ] = {} + self._custom_routing_detected: bool | None = None def _mark_routes_changed(self) -> None: self._routes_version += 1 + self._route_cache.clear() + self._custom_routing_detected = None + + def _check_custom_routing(self) -> bool: + if ( + type(self).matches is not APIRouter.matches + or type(self).handle is not APIRouter.handle + ): + return True + for route in self.routes: + if ( + isinstance(route, _IncludedRouter) + and route.original_router._check_custom_routing() + ): + return True + return False def _get_routes_version(self, seen: set[int] | None = None) -> int: if seen is None: @@ -2664,21 +2685,121 @@ class APIRouter(routing.Router): await self.lifespan(scope, receive, send) return - partial: tuple[BaseRoute, Scope] | None = None - for route in self.routes: - match, child_scope = route.matches(scope) - if match == Match.FULL: + if self._custom_routing_detected is None: + self._custom_routing_detected = self._check_custom_routing() + + if not self._custom_routing_detected: + # 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_optimized: tuple[BaseRoute, Scope, BaseRoute, Any] | None = None + for route in self.routes: + 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) + 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_optimized is None: + partial_optimized = ( + route, + child_scope, + leaf_route, + effective_context, + ) + + if partial_optimized is not None: + _, child_scope, leaf_route, effective_context = partial_optimized + 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 - if match == Match.PARTIAL and partial is None: - partial = (route, child_scope) - if partial is not None: - route, child_scope = partial - scope.update(child_scope) - await route.handle(scope, receive, send) - return + else: + # Fallback to standard Starlette matching loop to preserve custom router matches/handle subclasses + partial: tuple[BaseRoute, Scope] | None = None + for route in self.routes: + match, child_scope = route.matches(scope) + if match == Match.FULL: + scope.update(child_scope) + await route.handle(scope, receive, send) + return + if match == Match.PARTIAL and partial is None: + partial = (route, child_scope) + + if partial is not None: + route, child_scope = partial + scope.update(child_scope) + await route.handle(scope, receive, send) + return route_path = get_route_path(scope) if scope["type"] == "http" and self.redirect_slashes and route_path != "/": 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