Repository navigation
fix(server): JSON 404/405 for unknown /api paths and wrong methods - #266
Conversation
RegisterRoutes now ends with a PathPrefix("/api/") catch-all that
answers 404/405 (writeError JSON) for anything none of the real
/api/* routes matched. Before this, gorilla/mux let a path-match /
method-mismatch (e.g. POST /api/packets) fall through past every
registered route to the SPA catch-all in main.go, which answers any
method with 200 index.html.
Relates to #233, #223, #231
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rapport — CS-MacBook PR#266 #233 — head 7439f47Status: All required work done, local verification + CI both green, no open blockers. Summary
router.PathPrefix("/api/").HandlerFunc(apiFallbackHandler(router))registered as the last route inside
Because the fallback lives inside Not touched
Two pre-existing tests touched
TestsNew
Plus the updated Evidence[T] = automated test, [A] = automated/live-server check via curl/Playwright/CI against a built binary, [K] = known/accepted gap.
Before vs. afterBefore (captured as the "red" test output on this branch's After (live curl against a local build, port 13900, migrated copy of CI (per job)Local, in a dedicated worktree + branch:
GitHub Actions (
Everything passed on the first run; the known-flaky #256 (Hash Stats-sort) did not surface, so no rerun was needed. Rester (leftovers / out of scope)
|
Review — CS-Minimax PR#266 — head 7439f47Dom: REQUEST CHANGES Independent, read-only review. Reviewed the merged tree ( The core of the change is right, and the stated acceptance criteria hold. One finding blocks: the fallback converts Findings
Finding 1 — detailTwo servers built from the same fixture pipeline, master on 13700 and the merged tree on 13701. [A]
Why this is worth fixing rather than accepting:
Master's 200-HTML answer was of course also wrong. The point is that it was non-breaking, and this PR turns it into a hard failure rather than into the correct answer. Suggested direction: in the 405 branch, when the request method is Answers to the review points1. Unknown paths. [A] live, master vs PR: Trailing-slash variants of known paths land on 404 rather than 405 or a redirect ( 2. Wrong method. [A] 3. Nothing regresses.
4. Ordering. Confirmed by reading and by the 95-op sweep: On "can a route registered later be shadowed?" — yes, and that is now load-bearing. It is why 5. curl matrix. 29 method/path pairs, master (13700) vs PR (13701), full matrix captured. Excerpts under points 1, 2 and 3 above; the only cells that changed are unknown Standing checks
Tests I ranMerged tree against
Live E2E against local Go servers on fixture DBs prepared exactly as
The one Playwright failure is GitHub Actions on this head (run What I did not verify
|
#233) Review of PR #266 found that HEAD on every known /api route now answers 405, that bare /api still serves the SPA page, and that Allow was only checked with strings.Contains. - Walk the served OpenAPI list over a real listener: each GET route must answer HEAD with GET's status and headers and no body; a path without GET answers 405 with the exact Allow set. - GET/POST /api must be a JSON 404 on both router compositions. - /api-docs, /apifoo, /apiary, /apis/x and /api.json must still reach the SPA. - Allow is compared as an exact set, including in the #223 tests, and includes HEAD wherever GET is allowed. Relates to #233 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#233) gorilla/mux's .Methods("GET") does not match HEAD, so with the JSON fallback every HEAD on a known /api route became 405 (master answered 200 via the SPA). The fallback now looks up the route a GET to the same URL would reach and runs its handler; net/http drops the body because the connection's request is HEAD. It calls the route's own handler rather than router.ServeHTTP, so the router middleware runs once and the fallback cannot re-enter itself. Allow lists HEAD wherever GET is allowed. Bare /api gets its own exact-path fallback route, so it is a JSON 404 while /api-docs, /apifoo and other prefix siblings still reach the SPA. The OpenAPI walk now compares HEAD with the GET just before and just after it (analytics endpoints answer 202 until their background compute finishes), takes "no GET route" from GET's own 405 rather than from the spec (GET /api/packets/observations is served by /api/packets/{hash}), and checks the HEAD body over a raw connection, since http.Client never returns one. Relates to #233 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ck (#233) The JSON fallback is the last /api route RegisterRoutes adds, so an /api route registered on the same router afterwards (for example in main.go) is never reached, and nothing failed. - main.go's router composition (RegisterRoutes, /ws, the SPA catch-all) moves into newHTTPRouter, which main and the #233 tests now share. - The fallback routes are named, and apiRoutesShadowedByFallback lists every /api route registered after them. - TestProductionRouterHasNoShadowedAPIRoutes checks newHTTPRouter: the fallback is the last /api route and nothing is shadowed. TestAPIRoutesShadowedByFallbackReportsLateRoutes checks the guard. - main refuses to start if any /api route is shadowed, which also covers routes added in main after newHTTPRouter returns. Relates to #233 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rapport — CS-pve-agent2 PR#266 runde 2 — head 74dca70Review feedback addressed (commit Commits on top of round 1 (
Docs: TestsNew or changed in
Red before green [T]. Run at
The HEAD walk test changed between
Mutants (one or more per finding; each applied, run, reverted) [T]
Local runs
Servers were stopped with curl matrix (before / after) [A]Three local servers on the same prepared fixture: master The full 60-row matrix was captured; omitted rows follow the same pattern. Between "before" and "after", the only cells that changed are bare CI per job (run
|
| Job | Result | Duration |
|---|---|---|
| Go Build & Test | ✅ success (includes Test1690_BackgroundLoadHonesty, skipped locally) |
24m08s |
| 🎭 Playwright E2E Tests | ✅ success | 25m09s |
| 🏗️ Build & Publish Docker Image | ✅ success | 47s |
| 📦 Release Artifacts | skipped (tag-only gate) | — |
| 🚀 Deploy Staging | skipped (push/master-only gate) | — |
| 📝 Publish Badges & Summary | skipped (push-only gate) | — |
Overall: success on the first run. The known flakes (#267, #271) did not show up, so no rerun was needed.
Rester (leftovers)
- [K]
Test1690_BackgroundLoadHonestyhangs locally on this host on master too: isolated runs hit the 5 min timeout on both, with ~3 s of CPU, while the test seeds 5000 rows; I observed the ext4 journal thread in D-state at the time. It is an I/O-bound environment problem, unrelated to routing. I skipped it locally; CI ran it (see the CI table). - [K] The fallback serves
HEADby calling the matched route's handler withmux.SetURLVars, which gorilla/mux documents as intended for tests. It is the only exported way to attach path variables without re-enteringrouter.ServeHTTP, and re-entering would run the middleware twice (double perf counting) and risk a loop. TheHEADwalk test pins the behaviour, including routes with{params}. - [K] perfMiddleware buckets requests by the matched route template, so fallback-served
HEADrequests are counted under/api/(like the 404/405s), not under the GET route's key. Cosmetic; not changed. - [K]
OPTIONSon a known/apipath without an allowed CORS origin is still 405 (Allow: GET, HEAD), as in round 1. The reviewer considered that the intended direction. CORS preflight with an allowed origin is handled bycorsMiddlewarebefore the fallback and is unchanged. - [K]
noStoreAPIMiddlewareonly matches the/api/prefix, so the bare/apiJSON 404 carries noCache-Control: no-store. Harmless for a static 404; not changed, to keep scope. - [K] A trailing slash on a known path (
/api/observers/) is a 404, not a redirect, as in round 1 (StrictSlashis not set). - [K] The handler-level plain-text
http.NotFound404s noted in round 1 are unchanged and out of scope. - Nothing on staging or production was touched, and no API key was used.
🤖 Generated with Claude Code
Review — CS-Macmini PR#266 — head 74dca70Dom: APPROVE med nits Independent, read-only review of round 2. Reviewed the merged tree ( All five of the previous round's findings are fixed and I reproduced each fix on live servers. Nothing blocks. Four nits, two of them test gaps found by my own mutants. Findings
None of these change behaviour; all four are safe to leave or fix in a follow-up. Answers to the review points1. HEAD (the round-1 blocker). Fixed, verified by matrix rather than by spot checks. I pulled
2. Bare 3. Shadowing guard. Present and effective. 4. 5. No regression. 25 paths ×
Standing checks
Tests I ranMerged tree (
Live E2E against local Go servers on fixture DBs prepared exactly as
The one Playwright failure is Neither known flake (#256 Hash Stats sort, #267 backfill write-hold) surfaced; CI per job (run
|
| Job | Result | Duration |
|---|---|---|
| ✅ Go Build & Test | success | 24m08s |
| 🎭 Playwright E2E Tests | success | 25m09s |
| 🏗️ Build & Publish Docker Image | success | 47s |
| 📦 Release Artifacts | skipped (tag gate) | — |
| 🚀 Deploy Staging | skipped (push/master gate) | — |
| 📝 Publish Badges & Summary | skipped (push gate) | — |
Overall success on the first run; no rerun needed. Checked per job myself, not just the roll-up.
What I did not verify
- [K] Nothing on staging or production, no API key used, no writes to the PR or to upstream. Local binaries only.
- [K]
main'slog.Fatalfshadow guard: I verified it [A] with a mutated binary (exit 1), but no automated test coversmain()itself, so a regression in that one call site would only be caught by thenewHTTPRouter-level test. - [K] Behaviour behind a real CDN or reverse proxy. A proxy that rewrites or normalises
/apipaths, or that previously got a 200 for bare/api, could see different effects than a direct connection. - [K]
gzipMiddlewarewas not exercised with gzip actually enabled;Accept-Encoding: gzipprobes ran against the default (gzip-off) config, whereHEADandGETheaders matched exactly. - [K]
perfMiddlewarebuckets a fallback-servedHEADunder the/api/template rather than the GET route's key, so/api/perfunder-counts those endpoints by exactly the HEAD traffic. Cosmetic, the author's declared leftover, unchanged here. - [K]
noStoreAPIMiddlewarematches only the/api/prefix, so the bare/apiJSON 404 carries noCache-Control: no-store. Declared leftover. - [K] Handler-level
http.NotFound404s (/api/rx-coverage,/api/rx-leaderboard,/api/packets/observations, …) are stilltext/plainon both master and this head — pre-existing and out of scope, but it means a client still cannot assume every/api404 is JSON. - [K] Trailing slash on a known path (
/api/observers/) is a 404 rather than a redirect;StrictSlashis not set anywhere. Decision, not accident. - [K] No adversarial load test of the 404 path. The sequential measurement above showed no difference, but I did not drive concurrent unmatched-path traffic.
Relates to #233
Summary
Before this PR, an unknown
/api/*path, or a known one called with the wrong method, fell through to the SPA'sindex.htmlwith 200:RegisterRoutes(cmd/server/routes.go) now ends with one explicit catch-all:registered as the last route inside
RegisterRoutes, strictly after every real/api/*route in that function and strictly beforemain.golater registers/wsand the SPAPathPrefix("/"). gorilla/mux tries routes in registration order; a route whose path matches but whose method doesn't is a "keep trying", not a final 405 — that's why the fall-through happened. The new route intercepts it before it ever reaches the SPA handler.apiFallbackHandler(new filecmd/server/api_fallback.go) decides 404 vs 405 by re-walking the router and, for the incoming request's literal path, checking every other/api/*route's own declared method(s) viaroute.Matchwith the method swapped — i.e. "would this route's path pattern (including{params}) match this URL at all, regardless of method". This reuses mux's own path/regex matching (the same trickbuildOpenAPISpecalready uses) instead of reimplementing it. Non-empty set →405+Allow: <methods>; empty →404. Both use the existingwriteErrorJSON shape — no newmap[string]interface{}.Because the fallback lives inside
RegisterRoutes, every caller gets it for free:main.goand every test'ssetupTestServer.cmd/serverstays read-only; no SQL touched.Not touched
/wsis not under/api/, so it's never reached by the newPathPrefix("/api/")./api/...routes before the fallback, so they keep matching their own handlers./, static assets,#/...deep links) is registered later inmain.goand is unaffected.deploy.yml9,release-fast-path.yml1).One pre-existing test updated
TestPostPacketsRemovedFallsThroughToSPAInProductionRouter(#231) pinned the bug on purpose, noting "tracked in #233". It now asserts 405 with anAllowheader instead of the SPA page, since fixing #233 is exactly what flips that assertion. The read-only-DB angle (nothing written) is unchanged.One other existing test,
TestPerfMiddlewareSlowQuery(coverage_test.go), registered an extra/api/test-slowroute on the router after callingRegisterRoutes. Since the new catch-all is now the last routeRegisterRoutesadds, anything registered after it on the same router is shadowed — exactly the behavior the fix is supposed to produce for truly-unmatched paths. Moved that one registration to before theRegisterRoutescall; no other test in the suite does this pattern.Tests
New
cmd/server/api_fallback_test.go:TestAPIFallbackUnknownPathReturns404JSON— bare API router.TestAPIFallbackUnknownPathInProductionRouterReturns404JSON— full main.go-style composition (RegisterRoutes +/ws+ SPA catch-all).TestAPIFallbackPostPacketsReturns405NotSPA— locksPOST /api/packetsspecifically in the production-style router, asserting 405 +Allow: GET, not the SPA page.TestAPIFallbackKnownPathWrongMethodReturns405—DELETE /api/stats→ 405 +Allow: GET.TestAPIFallbackDoesNotShadowExistingRoutes— walks the live/api/specroute list (95 route entries, ≥20 required) and asserts each one's method+path still matches its own registered route template viarouter.Match, not the fallback's/api/template.TestAPIFallbackDoesNotAffectWebSocketOrSPA—/wsstill routes to the hub; a non-/apideep link still gets the SPA page.Updated
TestPostPacketsRemovedFallsThroughToSPAInProductionRouteras above.Evidence
[T] = automated test, [A] = automated/live-server check via curl/Playwright against a built binary, [K] = known/accepted gap.
GET /api/...→ JSON 404TestAPIFallbackUnknownPathReturns404JSON,TestAPIFallbackUnknownPathInProductionRouterReturns404JSON. [A] curl below.Allow;POST /api/packetslockedTestAPIFallbackKnownPathWrongMethodReturns405,TestAPIFallbackPostPacketsReturns405NotSPA, updatedTestPostPacketsRemovedFallsThroughToSPAInProductionRouter. [A] curl below.TestAPIFallbackDoesNotShadowExistingRoutes(route-template match against all 95 live/api/specentries),TestAPIFallbackDoesNotAffectWebSocketOrSPA. [A] Playwright E2E 132/135 passed (3 pre-existing skips, unrelated),test-node-liveness-e2e.js(35 checks, live WS) passed.map[string]interface{};cmd/serverread-onlywriteError/writeJSON; no SQL added —rg "INSERT|UPDATE|DELETE|REPLACE"over the new file is empty.docs/api-spec.mdError Responses section +openapi.goinfo.description.deploy.yml9,release-fast-path.yml1 — grep counts match baseline; neither file touched.Before vs. after
Before (captured as the "red" test output on this branch's
master-based starting point, prior to the fix — anhttptestrun through the exact same router constructionmain.gouses):After (live curl against a local build, port 13900, migrated copy of
test-fixtures/e2e-fixture.db):CI (per job)
Run locally in a dedicated worktree + branch (
codex/issue-233-api-json-404), not via the GitHub Actions UI from this session:cd cmd/server && go build ./...,go vet ./...— clean.gofmt -lon every touched/new file (api_fallback.go,api_fallback_test.go,routes.go,openapi.go,post_packets_removed_223_test.go,coverage_test.go) — no output (all formatted).go test ./...incmd/server— all passing, no regressions.sh test-all.sh— 219/219 files passed.test-fixtures/e2e-fixture.db, freshened + seeded exactly asdeploy.ymldoes, port 13900):test-e2e-playwright.js132/135 passed (3 skips are pre-existing flaky/fixture-shape skips baked into the test itself, unrelated to this change),test-node-liveness-e2e.js35/35 checks passed (exercises the live WS path).RegisterRoutes→ 5 of the new/updated tests failed as expected.RegisterRoutes(wrong placement, shadowing every real route) →TestAPIFallbackDoesNotShadowExistingRoutesfailed as expected.GitHub Actions CI on this PR has not been inspected from this session (no
gh pr checksrun after push); will need a look once it's run, including the known-flaky #256 (Hash Stats-sort) — rerun once if it's the only failure.Rester (leftovers / out of scope)
/api/*handlers (e.g.handleRxCoverage,handleObserverDetailfor an unknown id) call stdlibhttp.NotFounddirectly instead of the JSONwriteError, so a disabled-feature or not-found-resource 404 from inside a matched handler is plain-text, not JSON. This is pre-existing, unrelated to the router-level fallback this PR adds (confirmed via a live-server spec sweep), and out of scope for fix(server): unknown /api/* paths and wrong methods return 200 index.html instead of a JSON 404/405 #233 — not changed here.