Browse Source

feat: implement route caching for static paths in APIRouter to improve routing performance

pull/15963/head
Your Name 2 weeks ago
parent
commit
ec85469bef
  1. 70
      fastapi/routing.py
  2. 74
      tests/test_route_caching.py

70
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)

74
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
Loading…
Cancel
Save