Browse Source

Merge f21247ead8 into eb75fd078e

pull/15963/merge
Rajat Jain 4 days ago
committed by GitHub
parent
commit
93134e4e9b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 105
      benchmark_routing.py
  2. 145
      fastapi/routing.py
  3. 74
      tests/test_route_caching.py

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

145
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 != "/":

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