-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
This page explains FasterAPI's internal architecture. Read this before contributing — it covers why each component exists and how they interact.
Incoming ASGI Request
│
▼
┌──────────────────────────┐
│ Middleware Chain │ Built once at first request, cached.
│ (CORS → GZip → ...) │ Each middleware wraps the next as an ASGI app.
└────────────┬─────────────┘
│
▼
┌──────────────────────────┐
│ Faster.__call__ │ ASGI entry point. Routes to HTTP, WebSocket,
│ │ or Lifespan handler based on scope["type"].
└────────────┬─────────────┘
│ (HTTP)
▼
┌──────────────────────────┐
│ RadixRouter.resolve() │ O(k) path lookup (k = path segments).
│ │ Returns (handler, path_params, metadata).
└────────────┬─────────────┘
│
▼
┌──────────────────────────┐
│ _resolve_handler() │ Iterates pre-compiled _ParamSpec tuples.
│ │ Injects dependencies, parses params.
│ │ Zero per-request introspection.
└────────────┬─────────────┘
│
▼
┌──────────────────────────┐
│ Handler executes │ async def → event loop (uvloop)
│ │ plain def → process pool (CPU auto-detect)
└────────────┬─────────────┘
│
▼
┌──────────────────────────┐
│ _send_response() │ dict/Struct → msgspec.json.encode → bytes
│ │ Zero-copy: Rust encodes directly to bytes.
│ │ Pre-encoded headers avoid repeated .encode().
└────────────┬─────────────┘
│
▼
Response sent
│
▼ (if any)
BackgroundTasks.run()
File: FasterAPI/router.py
FastAPI (via Starlette) compiles each route path into a regex pattern and checks them sequentially on every request — O(n) where n = total routes. This is fine for 10 routes, but at 100+ routes the linear scan becomes measurable overhead.
Routes are decomposed into segments and inserted into a tree at startup:
Registered routes:
GET /users
GET /users/{id}
GET /users/{id}/posts
GET /health
GET /orgs/{org_id}/teams/{team_id}
Tree structure:
root
/ \
users health → handler
|
[leaf] → handler (GET /users)
|
{id} → handler (GET /users/{id})
|
posts → handler (GET /users/{id}/posts)
orgs
|
{org_id}
|
teams
|
{team_id} → handler
The _walk method uses iterative traversal (not recursion) for the common case:
while idx < n:
seg = segments[idx]
child = node.children.get(seg) # Try static match first
if child is not None:
node = child; idx += 1; continue
param_child = node.children.get("*") # Then try param wildcard
if param_child is not None:
params[param_child.param_name] = seg
node = param_child; idx += 1; continue
return None # No matchKey design choices:
- Static children are checked before param wildcards (most routes are static segments)
-
__slots__onRadixNodeeliminates per-instance__dict__— less memory, faster attribute access - Path splitting uses a list comprehension (
[s for s in path.split("/") if s]) — this hits CPython's fast C path
| Operation | Radix Tree | Regex (Starlette) |
|---|---|---|
| Lookup | O(k) where k = path segments | O(n) where n = total routes |
| 100 routes | ~3 segment checks | ~50 regex evaluations (avg) |
File: FasterAPI/dependencies.py
FastAPI calls inspect.signature() and typing.get_type_hints() on every request to figure out what a handler needs. These are expensive reflection operations.
At route registration time, compile_handler(func) introspects the handler once and produces a tuple of _ParamSpec objects:
Route registration (startup):
@app.get("/users/{id}")
async def get_user(id: str = Path(), q: str = Query(None)):
...
compile_handler(get_user) is called immediately.
Returns: (
_ParamSpec(name="id", kind=_KIND_PATH, ...),
_ParamSpec(name="q", kind=_KIND_QUERY, ...),
), is_async=True
At request time, _resolve_from_specs iterates the pre-compiled tuple with integer kind comparisons — no reflection, no isinstance chains:
Request time (hot path):
for spec in specs:
if spec.kind == _KIND_PATH: kwargs[spec.name] = path_params[spec.name]
elif spec.kind == _KIND_QUERY: kwargs[spec.name] = request.query_params.get(...)
...
class _ParamSpec:
__slots__ = ("name", "kind", "annotation", "default", "marker")-
kindis an integer constant (0–11), not an enum — integer comparison is faster thanisinstance -
__slots__avoids__dict__overhead -
@lru_cache(maxsize=512)oncompile_handlermeans the same function is never introspected twice - Dependencies (
Depends(...)) are compiled recursively — the entire dependency tree is pre-resolved
File: FasterAPI/request.py
Most handlers only need 1-2 request attributes (e.g., path params and body). Parsing all headers, query params, and cookies on every request wastes time.
FasterAPI's Request uses lazy properties:
@property
def headers(self) -> dict[str, str]:
h = self._headers # Check cache
if h is None: # First access → parse
raw = self._scope.get("headers", [])
h = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in raw}
self._headers = h # Cache for subsequent access
return hThe same pattern applies to query_params, cookies, and body. If a handler never accesses request.cookies, they're never parsed.
File: FasterAPI/middleware.py
Middleware is registered via app.add_middleware(CORSMiddleware, allow_origins=["*"]) and stored as (class, kwargs) pairs.
On the first request, the chain is built once by wrapping the core app in reverse order:
Registration order: [CORS, GZip, TrustedHost]
Build order (reversed): TrustedHost(GZip(CORS(app)))
Request flow:
→ TrustedHost.__call__ (checks Host header)
→ GZip.__call__ (buffers response for compression)
→ CORS.__call__ (injects CORS headers)
→ app._asgi_app (route dispatch)
The built chain is cached in self._middleware_app. Adding middleware after the first request invalidates the cache (sets it to None).
Each middleware is a valid ASGI app that wraps another ASGI app:
class CORSMiddleware(BaseHTTPMiddleware):
def __init__(self, app, **kwargs):
self.app = app # The next app in the chain
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send) # Pass through non-HTTP
return
await self.dispatch(scope, receive, send)File: FasterAPI/app.py (module-level _send_response)
When a handler returns a dict or msgspec Struct, the response path is:
dict → msgspec.json.encode(dict) → bytes → ASGI send
One allocation. msgspec's Rust core converts Python objects directly
to JSON bytes without an intermediate string step.
Compare to the standard approach:
dict → json.dumps(dict) → str → str.encode("utf-8") → bytes → send
Three allocations. Each creates a new Python object the GC must track.
Additionally, common header values are pre-encoded as module-level bytes constants:
_CT_JSON = b"application/json"
_HEADER_CT = b"content-type"This avoids calling .encode() on every response.
File: FasterAPI/concurrency.py
uvloop replaces Python's default asyncio event loop with one backed by libuv (the same C library that powers Node.js). It handles I/O polling, callback scheduling, and timer management in C instead of Python.
def install_event_loop() -> str:
try:
import uvloop
if _PY312_PLUS:
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
else:
uvloop.install()
return "uvloop"
except ImportError:
return "asyncio"This is called at module import time (_event_loop = install_event_loop()) so it's set before any async code runs.
| File | Responsibility |
|---|---|
app.py |
ASGI entry point, route registration, HTTP/WS/lifespan dispatch |
router.py |
Radix tree router + FasterRouter (sub-router/blueprint) |
dependencies.py |
Compiled DI, Depends(), param resolution |
request.py |
Lazy-parsed Request object |
response.py |
Response classes (JSON, HTML, Streaming, File) |
middleware.py |
CORS, GZip, TrustedHost, HTTPS redirect |
concurrency.py |
uvloop, sub-interpreters, thread/process pools |
exceptions.py |
HTTPException, validation errors, default handlers |
params.py |
Path, Query, Body, Header, Cookie, File, Form descriptors |
background.py |
BackgroundTasks (post-response execution) |
websocket.py |
WebSocket connection handler |
datastructures.py |
UploadFile, FormData |
openapi/ |
Auto-generated OpenAPI 3.0 schema + Swagger/ReDoc UI |