Skip to content

feat(api): a real global error boundary — JSON 500s, structured logs, and non-Error containment - #9756

Merged
JSONbored merged 1 commit into
mainfrom
fix/global-onerror-handler
Jul 29, 2026
Merged

feat(api): a real global error boundary — JSON 500s, structured logs, and non-Error containment#9756
JSONbored merged 1 commit into
mainfrom
fix/global-onerror-handler

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Why

The app had no app.onError(), so it ran on Hono's built-in default. That was never a crash risk — Hono always installs one, and compose() sets context.error before dispatching, which is what the PostHog capture middleware reads. But the default is wrong for this app in two ways and leaves a real hole in a third.

1. It answers c.text("Internal Server Error", 500) — a text/plain body from a JSON API. Every other route here answers { error: ... }, so a client parsing JSON hit a parse failure on precisely the response it most needed to read.

2. It logs console.error(err) — a raw Error. This codebase's forwarder keys on structured JSON ({level, event, …}), so an unhandled route error wasn't classified, and carried no method or path. A production 500 couldn't be traced back to the route that raised it.

3. The hole. compose() routes a caught throw to onError only when it is instanceof Error, and re-throws everything else. So throw "a string", a rejected promise carrying a plain object, or a library throwing a non-Error escaped the boundary entirely — no response, no log, and no context.error, so PostHog never saw it either.

I verified that empirically rather than reasoning about it: my first test asserted a non-Error "still produces a response somehow", and it failed — the request rejects. nonErrorBoundary closes it. A try/catch around next() is the right shape for exactly this case and only this case, since an Error thrown below has already become a response by the time next() resolves (which is why worker-posthog.ts documents that a try/catch there would never see one).

Deliberately preserved

An HTTPException keeps its own status. Collapsing those into 500s is the single most damaging thing a global handler can do, so 400/401/403/404/413/422/429 are each asserted. The check is duck-typed on getResponse, as Hono's own default is, rather than instanceof HTTPException — two copies of hono in one workspace module graph would break instanceof and silently turn every intentional 4xx into a 500. Asserted with a hand-rolled foreign exception carrying its own getResponse.

Never leaked

The body is a fixed code and nothing else — no message, stack, or cause. Several routes here are unauthenticated, and an error string can carry a token, a binding name, or an upstream URL. The detail goes to the operator-only log, bounded; and the log uses c.req.path (the matched path, no query string) so a token in a query parameter can't ride along. Both asserted, including a probe for the specific secret substrings.

Tests (13, all through real Hono dispatch)

JSON shape and content-type; zero leakage; one structured line with method+path; query strings excluded from logs; every HTTPException status preserved and not logged as a fault; non-Error containment with its own event; the boundary not double-handling Errors (exactly one log line, from onError); a circular/huge thrown value still producing a bounded line; a successful route untouched; and c.error still set so error capture is unaffected.

Run through a real app rather than a faked Context — status preservation and leakage are only meaningful end to end, and a hand-built context would let all of it pass while the wiring was wrong. Regression-checked against the existing route suites (auth, ingest/repo-scope security, index, route↔spec ratchet): 130 passing, no behavior change on the ~241 existing routes.

… and non-Error containment

The app had no app.onError(), so it ran on Hono's built-in default. That
was never a crash risk -- Hono always installs one, and compose() sets
context.error before dispatching, which is what the PostHog capture reads
-- but the default is wrong for this app in two ways, and leaves a real
hole in a third.

1. It answers `c.text("Internal Server Error", 500)`: a text/plain body
   from a JSON API. Every other route here answers `{ error: ... }`, so a
   client parsing JSON hit a parse failure on precisely the response it
   most needed to read. Now a 500 is `{ error: "internal_error" }`.

2. It logs `console.error(err)`, a raw Error. This codebase's forwarder
   keys on structured JSON, so an unhandled route error was not
   classified, and carried no method or path -- a production 500 could not
   be traced to the route that raised it. Now it is one structured line.

3. THE HOLE: compose() routes a caught throw to onError only when it is
   `instanceof Error`, and RE-THROWS everything else. So `throw "string"`,
   a rejected promise carrying a plain object, or a library throwing a
   non-Error escaped the boundary entirely -- no response, no log, and no
   context.error, so PostHog never saw it either. Verified empirically:
   without the new nonErrorBoundary the request REJECTS rather than
   responding. A try/catch around next() is the right shape for exactly
   this case and only this case, since an Error thrown below is already a
   response by the time next() resolves.

Deliberately preserved: an HTTPException keeps its own status. Swallowing
those into 500s is the single most damaging thing a global handler can do,
so every one of 400/401/403/404/413/422/429 is asserted. The check is
duck-typed on getResponse, as Hono's own default is, because two copies of
hono in one workspace module graph would break `instanceof` -- asserted
with a hand-rolled foreign exception.

Never leaked: the body is a fixed code and nothing else -- no message,
stack or cause. Several routes here are unauthenticated and an error
string can carry a token, a binding name or an upstream URL. The detail
goes to the operator-only log, bounded, and the log uses c.req.path (the
matched path, no query string) so a token in a query parameter cannot ride
along -- both asserted.

13 tests, all through real Hono dispatch rather than a faked Context,
including that c.error is still set so error capture is unaffected, that
a successful route is untouched, and that a circular/huge thrown value
still produces a bounded loggable line.
@loopover-orb

loopover-orb Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Warning

⏸️ LoopOver review result - manual review recommended

Review updated: 2026-07-29 07:11:26 UTC

3 files · 1 AI reviewer · 3 blockers · CI green · clean

⏸️ Suggested Action - Manual Review

Review summary
This PR adds `app.onError` + an outermost `nonErrorBoundary` middleware to replace Hono's default handler with a structured-JSON response/log, correctly preserving `HTTPException` status via duck-typed `getResponse` detection (guarding against cross-package instanceof failures) and closing the real gap where a thrown non-Error bypasses `onError` entirely (Hono's `compose()` only routes `instanceof Error` to it). The reasoning is verified empirically in the test suite (a bare-string throw is asserted to previously reject rather than respond), registration order in routes.ts places both handlers before the CORS/PostHog middleware so they wrap everything downstream, and `c.error` capture for PostHog is explicitly regression-tested. The description does not link this to any existing open issue, which this repo's contribution policy requires for external PRs.

Blockers

  • The PR description gives no issue reference (`Closes #...`) tying this feature work to a maintainer-authorized open issue, which this repo's contribution policy requires before merging unsolicited feature work.
Nits — 5 non-blocking
  • src/api/error-handler.ts:63/98/101 use a bare `500` literal three times; a small `const INTERNAL_ERROR_STATUS = 500` would match the `INTERNAL_ERROR_BODY` naming already established just above.
  • src/api/routes.ts is already ~1175 lines pre-diff; this PR only adds 8 lines to it so it isn't the cause, but worth flagging that any future route additions here should consider splitting the file.
  • The `console.error(JSON.stringify(...))` calls duplicate a very similar structured-log shape in two places (`handleAppError` and `nonErrorBoundary`); a tiny shared `logStructuredError(event, c, fields)` helper would avoid the two logging call sites drifting apart later.
  • Add the issue link the diff is closing, per repo contribution rules, before this is merge-ready.
  • Consider factoring the duplicated `console.error(JSON.stringify({level:'error', event, method, path, ...}))` shape in error-handler.ts:51-60 and :90-99 into one small helper to keep the two log shapes from diverging as the codebase evolves.

Concerns raised — review before merging

  • The PR description gives no issue reference (`Closes #...`) tying this feature work to a maintainer-authorized open issue, which this repo's contribution policy requires before merging unsolicited feature work.
  • No linked issue detected: No closing reference or linked issue number was found in the PR metadata/body. — If this PR is intended to solve an issue, link it explicitly in the PR body.
  • Maintainer requires a linked issue: This repo's maintainer focus manifest requires every PR to reference a tracked issue. — Link the relevant issue (for example Closes #123) before opening the PR.
📋 Copy for AI agents — paste into your coding agent
Fix the following blocker(s) from this PR review:

1. The PR description gives no issue reference \(\`Closes \#...\`\) tying this feature work to a maintainer-authorized open issue, which this repo's contribution policy requires before merging unsolicited feature work.

2. No linked issue detected: No closing reference or linked issue number was found in the PR metadata/body. — If this PR is intended to solve an issue, link it explicitly in the PR body.

3. Maintainer requires a linked issue: This repo's maintainer focus manifest requires every PR to reference a tracked issue. — Link the relevant issue (for example `Closes #123`) before opening the PR.

Decision drivers

  • ❌ Code review — 3 blockers (1 reviewer)
  • ❌ Gate result — Blocking (Repo-configured hard blocker found.)
Context & advisory signals — never blocks the verdict
Signal Result Evidence
Linked issue ⚠️ Missing No linked issue or no-issue rationale found.
Related work ✅ No active overlap found No same-issue or scoped active PR overlap found.
Change scope ✅ 20/20 Low review scope from cached public metadata (no linked issue context).
Validation posture ✅ 25/25 PR body includes validation/test evidence.
Contributor workload ✅ 10/10 Author activity: 14 registered-repo PR(s), 13 merged, 359 issue(s).
Contributor context ✅ Confirmed Gittensor contributor JSONbored; Gittensor profile; 14 PR(s), 359 issue(s).
Improvement ✅ Minor risk: clean · value: minor
Review context
  • Author: JSONbored
  • Role context: owner (maintainer lane)
  • Public audience mode: oss maintainer
  • Lane context: Repository is registered but has no active allocation in the current snapshot.
  • Public profile languages: not available
  • Official Gittensor activity: 14 PR(s), 359 issue(s).
  • PR-specific overlap: none found.
Contributor next steps
  • Start here: Treat this as maintainer-lane context rather than normal contributor-lane activity.
  • Then work through the remaining 3 steps in the Signals table above.
Signal definitions
  • Related work = same linked issue, overlapping active PRs, or title/path similarity.
  • Change scope = cached public metadata such as size labels, draft state, and review-burden hints.
  • Validation posture = whether the PR provides enough public validation/test evidence for maintainer review.
  • Contributor workload = public contributor activity and cleanup pressure, not a repo-wide quality failure.
  • Contributor context = public GitHub/Gittensor identity context; non-Gittensor status is not a blocker.
🧪 Chat with LoopOver

Ask LoopOver a question about this PR directly in a comment — grounded only in the same cached, public-safe facts shown above, never a new claim.

  • @loopover ask <question> answers contribution-quality Q&A with source citations and freshness.
  • @loopover chat <question> answers in natural prose from cached decision-pack facts via local inference (maintainer/collaborator; read-only).
  • A plain-language @loopover mention with a real question is routed to the closest matching read-only command automatically — no exact syntax required.

Full command reference: https://loopover.ai/docs/loopover-commands

🧪 Experimental — new and may change.

Decision record
  • action: hold · clause: missing_linked_issue
  • config: f2dc517edb20907f828c85a93583d5a7d66872c277001c79125ecd7ae1dbd045 · pack: oss-anti-slop · ci: passed
  • model: claude-code · prompt: fb2e25403180ae7550fe55742b1d5051563f41abd7fea6c267e6b6c8ae65552d · confidence: 0.55
  • record: 0402d8c665f41bdd5d1f0747d77b6c2ed0007d1187a50ce84f573398ca649acf (schema v5, head 88022d5)

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed


💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →.

Checked by LoopOver, a quiet PR intelligence layer for OSS maintainers.

  • Re-run LoopOver review

@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@JSONbored JSONbored self-assigned this Jul 29, 2026
@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.46%. Comparing base (5ecb771) to head (88022d5).
⚠️ Report is 9 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9756      +/-   ##
==========================================
- Coverage   90.27%   89.46%   -0.82%     
==========================================
  Files         904      905       +1     
  Lines      113177   113191      +14     
  Branches    26840    26842       +2     
==========================================
- Hits       102171   101261     -910     
- Misses       9676    10842    +1166     
+ Partials     1330     1088     -242     
Flag Coverage Δ
backend 94.06% <100.00%> (-1.48%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/api/error-handler.ts 100.00% <100.00%> (ø)
src/api/routes.ts 95.58% <100.00%> (+<0.01%) ⬆️

... and 3 files with indirect coverage changes

@loopover-orb loopover-orb Bot added the manual-review Gittensor contributor context label Jul 29, 2026
@JSONbored
JSONbored merged commit e5cc2cd into main Jul 29, 2026
7 checks passed
@JSONbored
JSONbored deleted the fix/global-onerror-handler branch July 29, 2026 07:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

manual-review Gittensor contributor context

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant