Repository navigation
Shared path tree with interceptor accumulation during lookup #58
Replies: 4 comments
|
Design constraint: overlapping route group prefixes must be rejected at startup, not silently resolved at runtime.
The failure should happen during |
Implementation Plan1. New types:
|
|
Implemented. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Supersedes #56, which identified the 404 interceptor gap but proposed fixing it within the existing per-method tree structure. Design exploration revealed that per-method trees are fundamentally wrong for path-scoped interceptors — the fix requires a structural change to the router.
Problem
Interceptors are path-scoped, but the router stores them only at leaf nodes in per-method trees. When a route lookup fails (404), no interceptors run except app-level response interceptors passed through a side channel (
_Listener→_Connection._app_response_interceptors).This has two consequences:
Group interceptors don't run on 404s. An auth request interceptor on
/apishould reject unauthenticated requests to/api/nonexistent. A CORS response interceptor on/apishould add headers to 404 responses under/api. Neither happens today.Per-method trees duplicate (or fail to duplicate) interceptors. Interceptors registered via a group with only GET routes exist only in the GET tree. A HEAD request to a non-existent path under that group accumulates no interceptors from the HEAD tree (which may not even have the group's nodes), producing different 404 behavior depending on which HTTP methods happen to have routes.
Design
Single shared path tree
Replace the per-method tree map (
Map[String, _BuildNode ref]) with a single shared path tree. Handlers are keyed by HTTP method at the leaf nodes. Interceptors live on the shared path nodes and are method-independent.Tree node structure changes:
_factory: (HandlerFactory | None)→ method-keyed map at the leaf (e.g.,Map[String, _MethodEntry]where_MethodEntrycarries the factory and per-route interceptors)_wildcard_factory→ same method-keyed map treatment_interceptorsand_response_interceptorsmove from leaf-only to every node (intermediate nodes carry group-level interceptors)HEAD→GET fallback simplifies: One traversal, then check HEAD key → GET key at the leaf. No second tree walk. Interceptors are identical for both methods (same path nodes traversed).
Interceptor accumulation during lookup
During
_lookup, each node the traversal visits contributes its interceptors. On a successful match, the handler gets the full accumulated set. On a failed match (404), the partial traversal produces interceptors accumulated to the deepest point.Pre-computation at freeze time: To avoid per-request allocation,
freeze()pre-computes the accumulated-from-root interceptor arrays at each node. Each_TreeNode valstores the cumulative interceptors from root to itself. On a hit, the leaf returns its pre-computed array. On a miss, the deepest reached node returns its pre-computed array. Zero allocation at request time for both paths.Lookup return type
lookup()returns(_RouteMatch | _RouteMiss)instead of(_RouteMatch | None)._RouteMatchis unchanged — factory (guaranteed), interceptors, response interceptors, params._RouteMissis new — carries accumulated request and response interceptors from the partial traversal. No factory, no params._Connection._process_requestmatches exhaustively on the union. On_RouteMiss: runs request interceptors (may short-circuit with 401/403), then sends 404 with response interceptors applied. On_RouteMatch: dispatches as today.Group info preserved to tree-building time
The current
RouteGroup._flatten_into()pre-concatenates group interceptors onto each route and discards the group identity. The tree builder never learns which prefix carried which interceptors.The new design separates group metadata from route flattening:
ApplicationandRouteGrouptrack_GroupInfoentries (prefix + interceptors) alongside flattened routes_flatten_into()no longer concatenates interceptors — it only joins paths and passes through per-route interceptorsApplication.serve()registers group infos with the builder viaadd_interceptors(path, req, resp), which tags intermediate nodesThe builder gets a method to register path-node interceptors separately from route handlers:
What gets removed
_app_response_interceptorsfield on_Connectionand_Listener— the tree root carries app-level interceptors; traversal accumulates them into every resultApplication.serve()— app interceptors go on the root node viaadd_interceptorsRouteGroup._flatten_into()— group interceptors go on group prefix nodes_RouterBuilderand_Router— single root node_ConcatInterceptorsand_ConcatResponseInterceptorsremain — used duringfreeze()to pre-compute accumulated arrays.Behavioral changes
Request interceptors run on 404
This is a breaking change from current behavior. Currently zero request interceptors run on any 404. Under the new design, traversal-accumulated request interceptors run on 404 for paths that traverse through group nodes. App-level request interceptors (on the root node) run on all 404s.
This is the intended behavior — the auth use case requires it. An auth interceptor that returns
InterceptRespondwill produce a 401/403 instead of 404, preventing information leakage about API structure to unauthenticated users.The
_RouteMisspath in_Connection: run request interceptors → if any short-circuit, respond with the intercept response (with response interceptors applied); if all pass, respond 404 (with response interceptors applied).Per-method group interceptors no longer possible
In the shared tree, a path node has one interceptor array — interceptors are path-scoped, not method-scoped.
Today, a user could create two groups with the same prefix and different interceptors for different methods:
GET /api/usersgets[CacheInterceptor],POST /api/usersgets[CsrfInterceptor]. This works today as an accident of per-method trees — each method has its own tree, so the two groups' interceptors never conflict. It was never a designed capability.In the shared tree,
/apiis one node. It can't carry both[CacheInterceptor]and[CsrfInterceptor]separately. The user's intent — different interceptors for different methods on the same path — is better expressed as per-route interceptors, which remain method-specific in the new design:Registering two groups with the same prefix should be treated as an error at
add_interceptorstime — it catches mistakes rather than silently picking a winner.What doesn't change
The user-facing API is identical.
add_request_interceptor(),add_response_interceptor(), group-level interceptors, per-route interceptors — all the same registration calls and signatures.All reactions