🔥 feat: add SkipUnmatchedRoutes with two-tier 404/405 fast path - #4486
Conversation
Adds a SkipUnmatchedRoutes config option that answers requests to unregistered paths with 404 (or 405 when the path exists for other methods) before the middleware chain runs, so work is not spent on bots, scanners, and bad URLs. Rather than a single full lookahead pass over the route tree on every request (which would duplicate next()'s matching work and regress every matched route), the existence check is split into two tiers built at startup in buildTree: - staticRouteMethods: an O(1) map from a static endpoint's prettified path to a bitmask of methods. Static hits run the normal chain with just one map lookup, and unmatched paths in buckets with no parametric/root/star/mount routes resolve to 404/405 with no scanning. - bucketParamMethods: a per-tree-bucket bitmask used to decide when the static index is authoritative versus when a parametric scan is needed. Only when a bucket actually contains parametric routes does a scan run, and it scans only the parametric subset, handing the matched index to next()/nextCustom() via ctx.firstMatchIndex so the endpoints already ruled out are not re-checked. The matched route is still re-matched in next() so params are recomputed correctly even if parametric middleware ran in between. firstMatchIndex defaults to -1, making the hot path inert when the option is disabled. Benchmarks (4-core): static matched +12ns (~239->251), parametric matched +82ns (~357->439), unmatched -134ns (~368->234). The option is off by default and has no effect on existing behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
Follow-up to the SkipUnmatchedRoutes two-tier fast path. - Precompute, per method and tree bucket, only the parametric/root/star endpoints together with their bucket index (App.paramRoutes). The tier-2 scan and the cross-method 405 scan now iterate just those candidates instead of walking the whole bucket and branch-skipping middleware and static routes. This cuts the parametric-matched overhead from roughly +23% to +3% over baseline (the example GET bucket had 31 routes, only 19 of them parametric). The candidate index is resolved against the same bucket next() iterates, so firstMatchIndex stays correct. - Gate the lookahead on whether any middleware is registered (App.skipHasUseRoutes). With no middleware the lookahead is pure duplicated work because next() already answers 404/405 without running anything before the endpoint; behavior is unchanged, just routed through next(). - Document the 63-method bitmask limit on the config option. - Expand tests to ~100% coverage of the new code: custom ErrorHandler -> 500, mixed static/param cross-method 405, bucket-0 fallback, next() skip-before guard, optional params, constraint routes, HEAD autoHead on a param route, group middleware skipping, and the no-middleware gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds a ChangesSkipUnmatchedRoutes fast-path
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4486 +/- ##
==========================================
+ Coverage 92.96% 92.97% +0.01%
==========================================
Files 138 139 +1
Lines 13609 13789 +180
==========================================
+ Hits 12651 12820 +169
- Misses 592 601 +9
- Partials 366 368 +2
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app.go`:
- Around line 117-121: The SkipUnmatchedRoutes bitmask handling in app.go needs
to respect the actual integer width used by staticRouteMethods and
bucketParamMethods. Update the logic around RequestMethods and the mask-building
code so the number of methods is capped or validated against the platform bit
width, or switch the masks to a fixed-width unsigned type and adjust the related
comment/doc in app.go accordingly.
In `@router_skip_test.go`:
- Line 10: The router skip tests are triggering httpNoBody lint failures because
several httptest.NewRequest calls in router_skip_test.go pass nil as the body.
Update each of the affected test cases to use http.NoBody instead of nil, and
add the net/http import alongside net/http/httptest so the http.NoBody reference
resolves; the changes should be made wherever these NewRequest calls appear in
the test file.
- Around line 352-365: The new subtests in router_skip_test.go are missing
t.Parallel() and currently share mutable state through app and called, which
makes them unsafe to run concurrently. Update the matched_runs_group_mw and
unmatched_skips_group_mw subtests to call t.Parallel() at the start, and move
any route/app setup and called initialization into each subtest so they are
fully isolated; use the existing test helpers and the app/called variables to
locate the shared setup that needs to be duplicated.
In `@router.go`:
- Line 465: The resolveSkip function signature uses unnamed return values, which
triggers the unnamedResult lint check; update resolveSkip on App to name its
three int results directly in the function signature and keep the existing
return behavior unchanged. Use the resolveSkip method as the target for the
signature change so the linter sees explicit result names.
- Around line 554-569: Reset ctx.firstMatchIndex in the Method(...) override
path so any earlier skip lookahead is discarded when the request method changes.
The issue is in the routing flow around app.resolveSkip and next(), where the
cached firstMatchIndex can be reused against the wrong method bucket after
Method(...) mutates the method; fix it by clearing that field in the same place
Path(...) and RestartRouting() already do, using the Method override logic and
ctx.firstMatchIndex as the key symbols to locate the change.
- Around line 466-467: The method bitmask logic in router.go is still using
int-based shifts in the route lookup path, so add a runtime guard in the
RequestMethods handling to enforce the documented maximum number of methods
before computing methodBit/staticMask. Update the relevant routing/method lookup
code around the bitmask construction and any Allow/SkipUnmatchedRoutes mask
usage to either validate the limit and fail fast, or switch the masks to a
fixed-width unsigned type consistently so oversized method lists cannot silently
corrupt lookups.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 1796c339-4503-4ee6-af91-2d5fc34e1e28
📒 Files selected for processing (7)
app.goctx.goctx_interface.goctx_interface_gen.godocs/api/fiber.mdrouter.gorouter_skip_test.go
- resolveSkip returns a named skipResult struct instead of three bare ints, resolving the gocritic unnamedResult finding without tripping nonamedreturns. - Reorder the App struct so skipHasUseRoutes groups with the other trailing bool fields, fixing the govet fieldalignment finding. - Move all SkipUnmatchedRoutes tests and benchmarks from router_skip_test.go into router_test.go, switch request bodies from nil to http.NoBody (gocritic httpNoBody), and flatten the group-middleware test so it no longer has order-dependent non-parallel subtests (tparallel). golangci-lint v2.12.2 now reports 0 issues; full suite passes; new-code coverage remains ~100%. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
There was a problem hiding this comment.
🧹 Nitpick comments (1)
router_test.go (1)
3164-3229: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider a one-time correctness assertion in the benchmarks.
The three benchmarks measure throughput but never verify the handler produced the intended outcome (200 for the matched/param cases, 404 for the unmatched case). A silent routing regression would still run "successfully" and report misleading parity numbers. A single sanity check before
b.Loop()cheaply protects the benchmark's intent.♻️ Example for the matched benchmark
c := &fasthttp.RequestCtx{} c.Request.Header.SetMethod(MethodGet) c.URI().SetPath("/user/repos") // genuinely static route + + // Sanity check: ensure the path actually matches before measuring. + appHandler(c) + require.Equal(b, StatusOK, c.Response.StatusCode()) b.ReportAllocs() b.ResetTimer() for b.Loop() { appHandler(c) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@router_test.go` around lines 3164 - 3229, Add a one-time correctness sanity check to each benchmark in Benchmark_SkipUnmatchedRoutes_Matched, Benchmark_SkipUnmatchedRoutes_MatchedParam, and Benchmark_SkipUnmatchedRoutes_Unmatched so the benchmark verifies the handler outcome before measuring throughput. After calling app.Handler() and before b.Loop(), invoke the handler once on the prepared fasthttp.RequestCtx and assert the expected status for the route type (200 for the matched and matched-param cases, 404 for the unmatched case). Keep the check lightweight and local to the existing run helper so the benchmark still measures only steady-state performance.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@router_test.go`:
- Around line 3164-3229: Add a one-time correctness sanity check to each
benchmark in Benchmark_SkipUnmatchedRoutes_Matched,
Benchmark_SkipUnmatchedRoutes_MatchedParam, and
Benchmark_SkipUnmatchedRoutes_Unmatched so the benchmark verifies the handler
outcome before measuring throughput. After calling app.Handler() and before
b.Loop(), invoke the handler once on the prepared fasthttp.RequestCtx and assert
the expected status for the route type (200 for the matched and matched-param
cases, 404 for the unmatched case). Keep the check lightweight and local to the
existing run helper so the benchmark still measures only steady-state
performance.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 0dbf62d9-f338-49d0-b67c-9801bd7ce5b6
📒 Files selected for processing (3)
app.gorouter.gorouter_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
- router.go
- app.go
- Switch the SkipUnmatchedRoutes method bitmasks (staticRouteMethods, bucketParamMethods, skipResult.allowMask, emitSkip) from int to uint64 so they stay 64-bit wide on 32-bit builds, matching the documented method-count limit. Updated the config doc note accordingly. - Reset ctx.firstMatchIndex when Method(override) changes the request method, mirroring Path() and RestartRouting(). Without this, a method switch mid-chain could reuse a lookahead index computed against the previous method's tree bucket and skip valid routes in the new bucket. Added a regression test that fails without the reset. golangci-lint v2.12.2 clean; full suite passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
Replace the ad-hoc skip benchmarks with the full benchmark set from PR #4411 (Unmatched, Matched, 405_Middleware, 405_NMiddleware, Deep) verbatim, so this implementation and the original can be compared on identical benchmark code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
There was a problem hiding this comment.
🧹 Nitpick comments (2)
router_test.go (2)
3164-3330: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRepetitive without_skip/with_skip setup across four benchmarks.
Each pair (
Unmatched,Matched,405_Middleware,405_NMiddleware) duplicates nearly identical app/middleware/RequestCtx setup, differing only inConfig.SkipUnmatchedRoutesand middleware count. A small helper (e.g.newSkipBenchApp(skip bool, middlewares int) (*fasthttp.RequestHandler, *fasthttp.RequestCtx)) parameterized by method/path would remove the duplication while keeping each benchmark isolated.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@router_test.go` around lines 3164 - 3330, The four SkipUnmatchedRoutes benchmarks duplicate the same app, middleware, and RequestCtx setup in both without_skip and with_skip branches. Refactor the repeated setup in the benchmark functions Benchmark_SkipUnmatchedRoutes_Unmatched, Benchmark_SkipUnmatchedRoutes_Matched, Benchmark_SkipUnmatchedRoutes_405_Middleware, and Benchmark_SkipUnmatchedRoutes_405_NMiddleware into a small helper that accepts SkipUnmatchedRoutes, middleware count, method, and path, then returns the configured app handler and request context so each benchmark stays isolated but concise.
3333-3350: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
statusfield indeepScenariosis never asserted, and one scenario name has a typo.
statusis populated per scenario but never read inside the benchmark loop (lines 3382-3386), so it currently serves only as documentation. Asserting it once during setup (beforeb.ResetTimer()) would let this table double as a regression guard if a future routing change silently changes the matched status. Separately,"Unmatched_Top_level_SBucket_WwongMethod"appears to be a typo for "WrongMethod".💚 Proposed addition to validate `status` before timing starts
appHandler := app.Handler() c := &fasthttp.RequestCtx{} c.Request.Header.SetMethod(s.method) c.URI().SetPath(s.path) + appHandler(c) + if c.Response.StatusCode() != s.status { + b.Fatalf("expected status %d, got %d", s.status, c.Response.StatusCode()) + } + b.ReportAllocs() b.ResetTimer() for b.Loop() { appHandler(c) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@router_test.go` around lines 3333 - 3350, The deepScenarios table in router_test.go includes a status field that is never checked and a scenario name with a typo. Update the benchmark setup in the deep routing test so each scenario’s expected status is asserted before b.ResetTimer(), using the existing deepScenarios entries to validate the response once during setup. Also rename the mislabeled scenario from “Unmatched_Top_level_SBucket_WwongMethod” to “Unmatched_Top_level_SBucket_WrongMethod” to keep the test case name accurate.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@router_test.go`:
- Around line 3164-3330: The four SkipUnmatchedRoutes benchmarks duplicate the
same app, middleware, and RequestCtx setup in both without_skip and with_skip
branches. Refactor the repeated setup in the benchmark functions
Benchmark_SkipUnmatchedRoutes_Unmatched, Benchmark_SkipUnmatchedRoutes_Matched,
Benchmark_SkipUnmatchedRoutes_405_Middleware, and
Benchmark_SkipUnmatchedRoutes_405_NMiddleware into a small helper that accepts
SkipUnmatchedRoutes, middleware count, method, and path, then returns the
configured app handler and request context so each benchmark stays isolated but
concise.
- Around line 3333-3350: The deepScenarios table in router_test.go includes a
status field that is never checked and a scenario name with a typo. Update the
benchmark setup in the deep routing test so each scenario’s expected status is
asserted before b.ResetTimer(), using the existing deepScenarios entries to
validate the response once during setup. Also rename the mislabeled scenario
from “Unmatched_Top_level_SBucket_WwongMethod” to
“Unmatched_Top_level_SBucket_WrongMethod” to keep the test case name accurate.
emitSkip took the Ctx interface, so the variadic c.Append(HeaderAllow, method) calls escaped to the heap (one alloc per allowed method) on 405 responses. Take the concrete *DefaultCtx in emitSkip so the variadic stays on the stack (0 allocs, matching next()'s own 405 path), and add emitSkipCustom for the rarer custom-context handler. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
Profiling the deep parametric with_skip path showed resolveSkip spending most of its non-match time on redundant map lookups that the static fast path doesn't need: - Replace the per-bucket bucketParamMethods bitmask (two int-map lookups per request) with a single app-level skipHasDynamicRoutes bool for the authoritative-miss check. - Give paramRoutes an entry for every treeStack bucket so its lookup ok result mirrors treeStack existence, folding away the separate treeStack[methodInt][treeHash] existence check. The static fast path (tier 1a) is untouched, so the static-route win is preserved. Deep parametric Matched/with_skip drops ~8% (194ns -> 179ns); the only remaining overhead vs a no-static-index scan is the static map lookup itself, which is what buys the static win. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
Reworks the previous attempt (an app-level skipHasDynamicRoutes bool), which regressed unmatched 404s by losing the per-bucket short-circuit, and could also mis-resolve 405 vs 404. resolveSkip now: - looks up this method's candidates once (paramRoutes has an entry for every treeStack bucket, so its ok result drives the bucket-0 fallback, removing the separate treeStack existence check), and - scans those candidates BEFORE the authoritative-miss check, so a matched request never pays for it, while - the authoritative miss uses bucketParamMethods[treeHash] | bucketParamMethods[0] (restored), which correctly accounts for other methods whose match would live in the treeHash bucket or fall back to bucket 0 — fixing a potential 404-instead-of-405 in the prior bucketKey approach. Net vs PR #4411 on its own benchmark suite: parametric Matched and 405_Middleware are now faster, deep parametric Matched is much improved (+22% -> ~+10%), and unmatched is back to ~parity. The static fast path (tier 1a) is untouched, so the static-route win is preserved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
On a parametric match the lookahead already computes the route's params into c.values; next() then re-matched the same route (a second getMatch), which dominated deep parametric with_skip cost. next()/nextCustom() now reuse those params and invoke the handler directly when the request has reached the pre-resolved endpoint, guarded by skipHasParamUse: the reuse only fires when no parametric/wildcard middleware could have overwritten c.values between the lookahead and the endpoint (otherwise it falls back to re-matching). The static fast path never sets firstMatchIndex, so it is unaffected. Matched/with_skip ~385ns -> ~358ns (now faster than the pre-lookahead scan). Added tests for multi-param reuse correctness and a parametric- middleware clobber guard (verified to fail without the guard). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
There was a problem hiding this comment.
Pull request overview
Adds an opt-in SkipUnmatchedRoutes router fast path to short-circuit unmatched requests (404 / 405) before middleware execution, aiming to reduce wasted work on bot/scanner traffic while keeping matched-route overhead low.
Changes:
- Introduces two-tier route-existence indexes built at startup (
staticRouteMethods,bucketParamMethods,paramRoutes) and aresolveSkip/emitSkipshort-circuit path. - Plumbs
firstMatchIndexthrough ctx/routing to avoid re-checking endpoints already ruled out by the lookahead (and optionally reuse params when safe). - Adds extensive tests/benchmarks and documents the new config option.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| router.go | Implements SkipUnmatchedRoutes lookahead/indexing, integrates fast path into request handlers, and adds firstMatchIndex endpoint-skipping logic. |
| router_test.go | Adds correctness tests and benchmarks for SkipUnmatchedRoutes behavior/perf across static/param/mount/custom ctx cases. |
| req.go | Invalidates firstMatchIndex when Method(...) overrides the request method. |
| ctx.go | Stores/invalidates firstMatchIndex across Reset/Path/RestartRouting and exposes getters/setters for custom ctx plumbing. |
| ctx_interface.go | Extends CustomCtx internal-method set with firstMatchIndex accessors. |
| ctx_interface_gen.go | Extends generated Ctx interface with firstMatchIndex accessors. |
| app.go | Adds router indexes/state fields to App and adds Config.SkipUnmatchedRoutes. |
| docs/api/fiber.md | Documents SkipUnmatchedRoutes in the public config table. |
Files not reviewed (1)
- ctx_interface_gen.go: Generated file
- Move the flash-cookie parse/clear above the SkipUnmatchedRoutes short-circuit in both request handlers, so flash messages are still cleared on a skipped 404/405 (matches behavior when the option is off). - Guard buildSkipIndexes against RequestMethods > 64: the per-method masks are 64-bit, so beyond 64 methods the fast path is disabled and requests fall through to the normal router (which still returns the correct 404/405). Documented on the config option; added a test. - Fix benchmark case name typo WwongMethod -> WrongMethod. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
There was a problem hiding this comment.
⚠️ Performance Alert ⚠️
Possible performance regression was detected for benchmark.
Benchmark result of this commit is worse than the previous benchmark result exceeding threshold 1.50.
| Benchmark suite | Current: ec7f3f8 | Previous: 0b57242 | Ratio |
|---|---|---|---|
Benchmark_Compress/Zstd (github.com/gofiber/fiber/v3/middleware/compress) - B/op |
1 B/op |
0 B/op |
+∞ |
This comment was automatically generated by workflow using github-action-benchmark.
The route-matching loop re-read loop-invariant state on every iteration. For next() these are struct fields (firstMatchIndex, shouldSkipNonUseRoutes, skipHasParamUse) that the compiler reloads each iteration because c is a pointer aliased by route.match(&c.values); hoisting them into locals is neutral-to-slightly-positive. For nextCustom the same values plus the request path/values were fetched through interface accessors (getDetectionPath, Path, getValues, getFirstMatchIndex, getSkipNonUseRoutes) once per iterated route. Hoisting them removes ~5 interface calls per bucket entry. Benchmark_Router_HandlerCustom (new, custom-context request over a deep bucket): 620ns -> 377ns (-39%), 0 allocs. Default-ctx routing benchmarks are unchanged. None of these values are mutated during the loop (path/method changes re-enter via RestartRouting), so the hoist is safe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
…hmark The custom-context 404/405 cross-method scan called getDetectionPath/ Path/getValues through the interface on every route of every method's bucket. Reuse the locals already hoisted at the top of nextCustom. Benchmark_Router_HandlerCustom_NotFound (new): -2.45% on the custom 404 path, 0 allocs. Completes the accessor-hoisting started in the previous commit for the matched path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
The cross-method fallback in next()/nextCustom() scanned every configured RequestMethod on each 404/405, doing up to two tree-bucket map lookups per method even for methods with no registered routes. Profiling the 404 path showed mapaccess_fast64 accounting for ~17% of samples. Precompute app.routeMethods, a bitmask of methods that own at least one non-use route (built in buildTree), and skip methods whose bit is clear before touching their buckets. Such methods can never contribute an Allow entry, so the result — 404 vs 405 and the Allow set — is unchanged. The mask is trusted only when RequestMethods fits in 64 bits (methodMaskValid); otherwise the fallback scans every method as before. Measured (benchstat, n=10): custom-ctx 404 -7.5%, unmatched 404 -2.5%, 405 (N middleware) -2.5%; matched paths and allocations unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
configDependentPaths runs on every request and, for the default case-insensitive configuration, copied c.path into c.detectionPath and then lowercased the copy in a second pass (append + UnsafeToLower). Since UnsafeToLower is a table-lookup loop rather than a SIMD pass, the copy is redundant work. Add appendLowerASCII, which writes the lowercased bytes straight from the source into the destination buffer in a single pass, reusing the buffer's backing array. The case-sensitive branch keeps the plain copy. Behavior is identical: only ASCII A-Z fold, bytes >= 0x80 pass through unchanged. Measured (benchstat, n=12): unmatched -2.1%, matched trending down, custom neutral; zero allocations. Covered by Test_appendLowerASCII. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
getMatch advanced both detectionPath and path by the same amount on every segment, but path is only read at parameter segments (path[:i]). Re-slicing it each iteration is a redundant slice-header write. Keep path fixed and track a running offset of consumed bytes, reading params as path[offset:offset+i]. detectionPath and path advance in lockstep and detectionPath is at most one trailing-slash byte shorter than path, so offset+i never exceeds len(path); the captured params are byte-identical. Measured (benchstat, n=10): parametric-matched custom path -2.75% (p=0.043), static/chain paths neutral, no parallel regression, zero allocation change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
getMatch walks parser.segs on every parametric match. The segments were each allocated separately, so the hot loop chased pointers into scattered heap locations; profiling attributed a large share of getMatch to the loop and per-segment field loads. After parsing, copy the segment values into a single contiguous backing array and repoint the slice entries at them. The slice type is unchanged (still []*routeSegment), the segments are read-only after registration, and this runs once per route at registration — no request-time allocation. Measured (benchstat, n=12): parametric-matched -4.9% and custom-ctx parametric -5.1% (both p=0.000), zero allocation change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
Two earlier optimizations caused CI failures and are reverted: - appendLowerASCII (fused copy+lowercase): triggered the modernize linter (rangeint) and reimplemented what gofiber/utils already provides. Restore the utils-based UnsafeToLower path in configDependentPaths. - contiguous-segment packing in parseRoute: allocated a second backing array per route at parse time, ~doubling Benchmark_RoutePatternMatch B/op (336 -> 624) and Benchmark_Startup_Process B/op, tripping the benchmark regression gate. The request-time speed gain is not worth the startup allocation cost. The allocation-neutral getMatch offset change is kept. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
The feature added seven fields to the App struct. Group them into a dedicated skipRouteIndex struct and move all related logic into a new router_skip.go: - skipRouteIndex holds the lookahead indexes (staticMethods, bucketParamMethods, paramRoutes, hasUseRoutes, hasParamUse) plus the 405-fallback method mask (routeMethods, methodMaskValid). - App now carries a single `skip skipRouteIndex` field instead of seven. - resolveSkip, emitSkip, emitSkipCustom, buildSkipIndexes, indexedRoute and skipResult move to router_skip.go; buildTree just calls app.buildSkipIndexes(). Pure reorganization; no behavior change. Full suite and golangci-lint pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
- replace the triple-map lookahead with a per-bucket skipBucket index (paramMask + per-method candidates, bucket-0 fallback materialized at build time), one map lookup per unmatched request instead of up to 2+2N: unmatched 132->118ns, 405 779->710ns, matched unchanged, 0 allocs - gate the fast path on a single precomputed enabled bool - rename skipMethodNot to skipNotAllowed and type the decision consts - fix a wrong invariant comment in getMatch (TrimRight strips all trailing slashes, not one byte) and trim comments to the surrounding density - reorder ctx.go setters so go generate is a no-op again - document that middleware never runs for skipped requests (CORS preflight, catch-all 404 pages, static/proxy/healthcheck, loggers); add the whats_new entry - add tests: flash-cookie clearing on skipped 404, Path override invalidation, mounted error handler, error sentinels, star-middleware re-match, UnescapePath, emitSkipCustom 500 fallback; extend the parity test to bodies and Content-Type; consolidate the four copy-paste benchmarks into one table-driven benchmark Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Two open questions from reviewing/optimizing this branch (0b526b0, 4c609a3):
Benchmarks (Apple M2 Pro,
All paths stay at 0 allocs/op. With the flag off there is no regression; the always-on 405 method-mask prune makes unmatched/405 requests 8-16% faster than main even then. The four copy-paste |
|
@ReneWerner87 I prefer The CORS issue is concerning. We need to fix that, since its a weird edge case to introduce just because of this flag. |
…ormance-q0qh6o # Conflicts: # router.go
SkipUnmatchedRoutes answers unmatched requests with 404/405 before the middleware chain runs, which broke CORS preflight: an OPTIONS request to a path registered only for GET/POST was short-circuited to 405 before the cors middleware could respond, blocking every cross-origin call to that path. Skip the fast path for CORS preflight requests (reusing the existing Ctx.IsPreflight(): OPTIONS + Access-Control-Request-Method + Origin) in both defaultRequestHandler and customRequestHandler, so preflight flows through the normal chain and cors middleware answers it. Non-preflight OPTIONS still hit the fast 404/405, preserving the feature's value against bot/scanner traffic. The guard sits behind app.skip.enabled, so there is no cost when SkipUnmatchedRoutes is off. When on, non-preflight requests pay one method lookup plus a length-mismatch string compare (a few ns, 0 allocs). Also fix a truncated doc comment on skipRouteIndex.staticMethods. Tests: preflight exemption + non-preflight 405 on both the default- and custom-context handlers. Docs updated (config comment, fiber.md, whats_new). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
CORS preflight exemption (ec7f3f8) Addresses the OPTIONS/CORS-preflight gap noted for Problem: with the option on, an Fix: both Benchmarks (
0 allocs/op throughout (unchanged). The Tests: preflight-exemption + non-preflight-405 on both the default- and custom-context handlers. Docs updated (config comment, Known limitation: a real cross-origin GET/POST to a fully unregistered path still fast-404s without CORS headers (browser reports a CORS error rather than a 404). The main breakage - preflight failing - is resolved. |
Adds a SkipUnmatchedRoutes config option that answers requests to unregistered paths with 404 (or 405 when the path exists for other methods) before the middleware chain runs, so work is not spent on bots, scanners, and bad URLs.
Rather than a single full lookahead pass over the route tree on every request (which would duplicate next()'s matching work and regress every matched route), the existence check is split into two tiers built at startup in buildTree:
Only when a bucket actually contains parametric routes does a scan run, and it scans only the parametric subset, handing the matched index to next()/nextCustom() via ctx.firstMatchIndex so the endpoints already ruled out are not re-checked. The matched route is still re-matched in
next() so params are recomputed correctly even if parametric middleware ran in between. firstMatchIndex defaults to -1, making the hot path inert when the option is disabled.
Benchmarks (4-core):
The option is off by default and has no effect on existing behavior.
Replaces #4411
Fixes #4403