Skip to content

feat(router)!: replace httprouter with an in-house route table - #141

Merged
Suhaibinator merged 6 commits into
mainfrom
feat/routetree
Sep 28, 2026
Merged

Suhaibinator merged 6 commits into
mainfrom
feat/routetree

Conversation

@Suhaibinator

Copy link
Copy Markdown
Owner

Summary

SRouter no longer depends on github.com/julienschmidt/httprouter (v1.3.0, unmaintained since 2019). Routing now runs on pkg/router/internal/routetree, a segment-trie route table written from a spec: docs/plans/route-table.md.

The spec came from SRouter's docs and tests plus black-box observation of httprouter's public API. No httprouter source was read or copied, so SRouter doesn't take on its BSD-3 license.

Behaviour

Kept:

  • :name and *name syntax.
  • 404, and 405 with Allow.
  • Automatic OPTIONS; explicit OPTIONS routes still win.
  • Trailing-slash redirects (301 for GET, 307 otherwise).
  • Cleaned-path redirects.

Changed on purpose (full table in the spec):

  • /users/new can be registered next to /users/:id; the static route wins, and matching backtracks. Previously this panicked at registration.
  • Registration problems are Build errors, never panics.
  • Security fix: redirects only go to clean paths that match a route. httprouter v1.3.0 answers GET //evil.com/../ on a /:slug route with 301 Location: http://evil.com/.., an open redirect. It also sometimes redirects to paths that match nothing.
  • No case-correcting redirects.
  • Parameters never match an empty segment.
  • Mid-segment wildcards (/user_:name) and repeated wildcard names are rejected.

Breaking API change

scontext.GetPathParams, WithRouteInfo, SetRouteInfo and router.GetParams now use scontext.Params instead of httprouter.Params. Code that only calls ByName, indexes or ranges over the result compiles unchanged. Migration notes are in docs/context-management.md.

Consumers checked

I audited every route in go-common (168 routes) and gradethis:

  • All routes are static.
  • Neither repo calls the path-param APIs or imports httprouter.
  • No client uses different letter case, trailing slashes or empty segments.

Everything they rely on is kept and now pinned by router-level tests (pkg/router/route_table_test.go): explicit OPTIONS handlers, 405 for a wrong method, automatic OPTIONS, and exact-path dispatch.

Benchmarks

The first commit (dce207d) contains a side-by-side benchmark suite and a differential test that run httprouter through its public API. The second commit removes both along with the dependency. To reproduce: check out dce207d, then run go test -run '^$' -bench Compare -benchmem -count 10 ./pkg/router/internal/routetree and benchstat -col /router.

The runs below (Apple M4 Max) were on a busy machine, with load averages between about 7 and 20.

Memory, deterministic: bytes and allocations per request are equal or lower on every row.

httprouter routetree
1-param request 64 B 32 B
405 184 B, 7 allocs 88 B, 5 allocs
redirect 256 B, 4 allocs 16 B, 1 alloc
build 1,000 flat routes 109 KiB, 2,413 allocs 75 KiB, 22 allocs
retained heap, 1,000 flat routes 92.6 KiB 35.4 KiB
retained heap, GitHub API set 36.8 KiB 31.6 KiB

Time (benchstat, n=10), from the lower-load run:

  • Significantly faster:
    • static routes −35% to −57%;
    • 1–2 parameters −24% to −73%;
    • 5 parameters −10%;
    • 404 −24%; 405 −29%; redirect −65%;
    • GitHub catch-all −68%;
    • the full 169-route GitHub set −20%;
    • builds −19% to −36%.
  • Not yet shown to be faster:
    • rest_catchall, github_param2 and the 1,000-route flat build: inconclusive (~) under load; the catch-all and github_param2 came in 15% and 30% faster in a quieter short run;
    • github_param4: +2.6% (p=0.004), with both routers running at about twice their quiet-machine times.

A second run at load around 10 agreed except for 404 and the GitHub build. In that run routetree's 404 time jumped to 376–722 ns between runs, and httprouter's timings varied by ±122%. These rows should be re-checked on a quiet machine.

Whole router (./pkg/router benchmarks; main and this branch alternated 8 times):

  • No significant change in any per-request benchmark (−3% overall).
  • Route-tree builds 7–23% faster, with 11–24% fewer allocations.
  • One regression: building a single-route router allocates 368 B more, the fixed size of the static-route filter.

Testing

  • go test -race ./..., go vet ./... and golangci-lint run all pass (0 issues).
  • The internal/doccheck link check passes, and the examples build.
  • Coverage with CI's settings is 99.4% in total. routetree is now in CI's -coverpkg list.
  • Spec tests cover every row of the compatibility tables. There is also a test that no redirect leaves the host, run through a real httptest.Server.
  • FuzzMatch and FuzzCleanPath found no failures.
  • In the first commit, the differential test served 120,000 random requests through both routers. 112,111 responses were byte-identical. Every difference falls into one of the deliberate categories above.

🤖 Generated with Claude Code

Suhaibinator and others added 2 commits September 28, 2026 08:36
Add pkg/router/internal/routetree, a segment-trie route table written from
the spec in docs/plans/route-table.md, and switch Router to it. The spec was
derived from SRouter's docs and tests plus black-box observation of
httprouter's public behaviour; no httprouter source was read or copied.

- Static and wildcard segments can share a position (/users/new next to
  /users/:id); matching backtracks and never depends on registration order.
- Registration problems are Build errors, never panics.
- Unmatched requests keep trailing-slash and cleaned-path redirects, 405
  with Allow, and automatic OPTIONS. Redirects only target clean paths that
  match a route, which closes an open redirect in httprouter v1.3.0
  (GET //evil.com/../ on /:slug redirected to http://evil.com/..).
- Case-correcting redirects are dropped; parameters no longer match empty
  segments; mid-segment wildcards and repeated wildcard names are rejected.

BREAKING CHANGE: scontext.GetPathParams, WithRouteInfo, SetRouteInfo, and
router.GetParams use scontext.Params instead of httprouter.Params.

This commit keeps a differential test and a benchmark suite that run
httprouter side by side through its public API; the next commit removes
them together with the dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Remove the temporary differential test and side-by-side benchmarks that ran
httprouter through its public API, keep the route table benchmarks on their
own, and tidy go.mod. Count the route table in CI's coverage packages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.93%. Comparing base (f3bdd70) to head (1b9ba16).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #141      +/-   ##
==========================================
+ Coverage   99.92%   99.93%   +0.01%     
==========================================
  Files          26       31       +5     
  Lines        2629     3180     +551     
==========================================
+ Hits         2627     3178     +551     
  Misses          2        2              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Suhaibinator and others added 4 commits September 28, 2026 08:49
Add tests for misses and route reuse under hashed child sets, routes with
more parameters than the matcher's stack buffer, appending to a caller's
parameter buffer, the trailing-slash hint from a sibling, ServeUnmatched
called directly, TRACE, and non-ASCII query bytes in redirects. Drop the
unreachable root-path branch from toggleTrailingSlash; both callers exclude
the root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The route table reports invalid and conflicting patterns as errors and never
panics, so handleRoute no longer needs to recover. Build keeps its own
recover as the outer safety net.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Update the prometheus/common dependency to the latest version.
This ensures the project remains up-to-date with the latest bug
fixes and improvements provided by the upstream library.
@Suhaibinator
Suhaibinator merged commit 8612569 into main Sep 28, 2026
9 checks passed
@Suhaibinator
Suhaibinator deleted the feat/routetree branch September 28, 2026 16:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant