Skip to content

Commit 0ced0aa

Browse files
os-salesclaude
andauthored
fix(hono): answer a raw-mount escaped throw with the declared ADR-0112 envelope (#17643)
* fix(hono): answer a raw-mount escaped throw with the declared ADR-0112 envelope A route mounted through `IHttpServer.getRawApp()` funnels through neither `wrap()` nor any registrar wrapper, so its escaped throw reached Hono's own default handler: `500 text/plain "Internal Server Error"`, with the thrown value's declared `status`/`code` discarded. Install a transport error seam on the raw handle that renders the SAME `declaredEnvelopeForThrow` gate `wrap()` opted into, so `/raw/*` and a direct-mount route answer one shape. The observation seam's rejected-request status now reads that same rule: it defaulted to 500 because Hono's error path always sent 500, which stops being true here, and `http_requests_total{status}` is armed off that seam. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com> * test(hono): keep the raw-mount pin's Hono handlers `never`-returning, add the changeset A Hono handler may not return `void`, and only a body whose statement IS the `throw` infers `never` — so each door throws a value a factory hands back instead of calling a shared throwing helper. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1f0b565 commit 0ced0aa

3 files changed

Lines changed: 564 additions & 3 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/plugin-hono-server': patch
3+
---
4+
5+
**`getRawApp()` mounts now answer an escaped throw with the declared ADR-0112 envelope.** A route mounted on the Hono handle funnels through neither the adapter's `wrap()` nor any registrar wrapper, so an escaped throw was answered by Hono's own default handler — `500 text/plain "Internal Server Error"`, no `success` flag, no `code`, and the thrown value's own declared `status` / `code` discarded. A transport error seam on the raw handle now renders the same throw-to-envelope rule a direct-mount route already used, so both doors answer one shape: a throw declaring `503` / `SERVICE_UNAVAILABLE` answers `503 application/json` with `{"success":false,"error":{"code":"SERVICE_UNAVAILABLE",…}}`, and a throw declaring no envelope still answers `500` with no cause in the body.
6+
7+
The escape hatch is unchanged: consumers still mount framework-natively, still stay outside `getMountedRoutes()`, and still need no adapter verb. A thrown value carrying its own `Response` (Hono's `HTTPException`) keeps the response it declared. A consumer that installs its own `getRawApp().onError(...)` replaces the seam.
8+
9+
Also fixed alongside it: `afterResponse` observers — and therefore `http_requests_total{status}` — reported a hard-coded `500` for any request that ended in a throw, which stops being the status actually sent once a declared envelope is rendered.

packages/plugins/plugin-hono-server/src/adapter.ts

Lines changed: 157 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ export class HonoHttpServer implements IHttpServer {
416416
private drainTimeoutMs: number = 10_000,
417417
) {
418418
this.app = new Hono();
419+
this.installErrorEnvelopeSeam();
419420
}
420421

421422
// internal helper to convert standard handler to Hono handler
@@ -770,6 +771,148 @@ export class HonoHttpServer implements IHttpServer {
770771
}
771772
}
772773

774+
/**
775+
* Report a throw that escaped to the TRANSPORT seam — the diagnostic exit
776+
* for the population {@link reportHandlerFailure} cannot see.
777+
*
778+
* Two reporters, DISJOINT populations: `wrap()` catches everything a
779+
* {@link RouteHandler} throws and reports it there, so nothing reaching
780+
* this method has been reported already and nothing it reports will be
781+
* reported again. Hono's default handler wrote these to `console.error`;
782+
* routing them through the host logger is what puts a raw-mount failure in
783+
* the same stream as every other one.
784+
*
785+
* `error`, not `warn`, for the reason {@link reportHandlerFailure} states:
786+
* an unhandled throw out of a handler is a server-side defect, and the
787+
* AGENTS.md "handed to the CALLER" exemption does not apply to a throw
788+
* nobody caught. Method and path only — never the body.
789+
*/
790+
private reportTransportEscape(
791+
c: any,
792+
thrown: unknown,
793+
rendered?: { status: number; code: unknown },
794+
): void {
795+
try {
796+
const method = typeof c?.req?.method === 'string' ? c.req.method : undefined;
797+
const path = typeof c?.req?.path === 'string' ? c.req.path : undefined;
798+
this.logger.error(
799+
rendered
800+
? '[hono] a throw escaped to the transport — request answered with the throw\'s declared ADR-0112 envelope'
801+
: '[hono] a throw escaped to the transport — request answered 500 INTERNAL_ERROR with no cause in the body',
802+
toLoggableError(thrown),
803+
rendered
804+
? { method, path, status: rendered.status, code: rendered.code }
805+
: { method, path },
806+
);
807+
} catch {
808+
// Same discipline as {@link reportHandlerFailure}: a host logger
809+
// that throws would otherwise reject the error seam itself and
810+
// hand the caller Hono's opaque page — precisely the answer this
811+
// seam exists to remove.
812+
}
813+
}
814+
815+
/**
816+
* Answer a throw that escaped to the TRANSPORT with the declared ADR-0112
817+
* envelope — the seam a route mounted through {@link getRawApp} funnels
818+
* through (#17411).
819+
*
820+
* ## The door this closes
821+
*
822+
* {@link wrap} catches everything a {@link RouteHandler} throws, so every
823+
* route registered through {@link get} / {@link post} / … already answers
824+
* the declared envelope (#16545). A route mounted on the framework handle
825+
* passes through NEITHER `wrap()` nor any registrar wrapper, so its
826+
* escaped throw reached Hono's own default handler — measured on
827+
* `7d350a46`, one `HonoHttpServer`, the raw pair mounted the way
828+
* `marketplace-install-local-plugin.ts` mounts:
829+
*
830+
* ```
831+
* /raw/envelope -> 500 text/plain; charset=UTF-8 Internal Server Error
832+
* /raw/plain -> 500 text/plain; charset=UTF-8 Internal Server Error
833+
* ```
834+
*
835+
* Byte-identical: a throw that DECLARED `503` / `SERVICE_UNAVAILABLE` and
836+
* a bare driver error answered the same thing, so the transport discarded
837+
* the producer's own declaration — the half of the defect that is
838+
* invisible from the producer's side, which is where anyone would look.
839+
*
840+
* ## Why the transport, and why this does not close the escape hatch
841+
*
842+
* {@link getRawApp}'s exemption is scoped to framework-native MOUNTING and
843+
* to route introspection, never to the wire shape of a refusal. The
844+
* contract says raw-handle mounts are "outside this table by construction
845+
* … this answers 'what routes did I register', not 'what paths might
846+
* respond'" ({@link IHttpServer.getMountedRoutes}), and the same contract
847+
* requires the unmatched answer to carry "the shared not-found error body
848+
* (the `errors.zod` envelope), never an adapter-native error page". An
849+
* error seam on the handle leaves the hatch fully intact: consumers still
850+
* mount natively, still stay outside `getMountedRoutes()`, still need no
851+
* adapter verb. It is the reasoning {@link installHttpMetricsSeam}'s
852+
* seam already rests on (#9650) — the transport is the one layer every
853+
* inbound request converges on, whatever registered the handler.
854+
*
855+
* ## ONE rule, not a second one
856+
*
857+
* The render is {@link declaredEnvelopeForThrow}, the same gate `wrap()`
858+
* opted into, so `/raw/*` and a direct-mount route answer the same shape
859+
* for the same throw — the `ValidationError`-shape-as-declaration limb
860+
* included. The fallback arm is the ADR-0112 `INTERNAL_ERROR` body
861+
* carrying {@link INTERNAL_ERROR_MESSAGE}: a non-envelope throw still
862+
* answers 500 with NO cause in the body, #16545's pinned invariant.
863+
*
864+
* ⛔ It deliberately does NOT copy `wrap()`'s literal `"No response from
865+
* handler"`. That sentence describes a handler that wrote nothing — a
866+
* state this seam never observes, because a Hono handler that returns
867+
* nothing is Hono's own error, not ours. Copying it would put a false
868+
* diagnosis on the wire; the `code` and the `status`, which are what a
869+
* client branches on, agree with `wrap()` exactly.
870+
*
871+
* ## Hono's own declared-`Response` limb is preserved
872+
*
873+
* Hono's default handler honours a thrown value carrying its own
874+
* `Response` (`HTTPException`) before falling back to
875+
* `text('Internal Server Error', 500)`, and that limb is kept verbatim: an
876+
* `HTTPException` is a framework-native refusal the producer DECLARED, and
877+
* overriding it would be this card's own defect with the roles reversed.
878+
* Measured at `7d350a46`: zero `HTTPException` producers anywhere in
879+
* `packages/`, so this preserves behaviour rather than adding any.
880+
*
881+
* ## Installed from the constructor, and overridable on purpose
882+
*
883+
* Once, unconditionally, so a bare `HonoHttpServer` (cloud's serverless
884+
* entrypoints, tests) gets it without wiring — the same reason
885+
* {@link setLogger}'s default is a real logger. A consumer that calls
886+
* `getRawApp().onError(...)` itself replaces it, which is the escape hatch
887+
* working as designed.
888+
*/
889+
private installErrorEnvelopeSeam(): void {
890+
this.app.onError((err: Error, c: any) => {
891+
// Hono's own precedence, unchanged — see the docblock.
892+
if (err !== null && typeof err === 'object' && 'getResponse' in err) {
893+
const declared = (err as unknown as { getResponse(): Response }).getResponse();
894+
return c.newResponse(declared.body, declared);
895+
}
896+
897+
const envelope = declaredEnvelopeForThrow(err);
898+
this.reportTransportEscape(
899+
c,
900+
err,
901+
envelope ? { status: envelope.status, code: envelope.body.error.code } : undefined,
902+
);
903+
904+
return envelope
905+
? c.json(envelope.body, envelope.status)
906+
: c.json(
907+
{
908+
success: false,
909+
error: { code: 'INTERNAL_ERROR', message: INTERNAL_ERROR_MESSAGE },
910+
},
911+
500,
912+
);
913+
});
914+
}
915+
773916
get(path: string, handler: RouteHandler) {
774917
this.registeredRoutes.push({ method: 'GET', pattern: path });
775918
this.app.get(path, this.wrap(handler));
@@ -1311,13 +1454,24 @@ export class HonoHttpServer implements IHttpServer {
13111454
this.app.use('*', async (c, next) => {
13121455
if (this.responseObservers.length === 0) return next();
13131456
const startedAt = Date.now();
1314-
// Default 500: if `next()` rejects, Hono's error path renders the
1315-
// 500 and `c.res` is not yet set — reading it would synthesize a
1316-
// response and change what the caller receives.
1457+
// Default 500: if `next()` rejects, `c.res` is not yet set here —
1458+
// reading it would synthesize a response and change what the
1459+
// caller receives.
13171460
let status = 500;
13181461
try {
13191462
await next();
13201463
status = c.res.status;
1464+
} catch (err) {
1465+
// … and 500 stopped being the whole answer with #17411: the
1466+
// transport error seam ({@link installErrorEnvelopeSeam}) may
1467+
// render a DECLARED status for this throw, and it runs after
1468+
// this middleware unwinds. `HttpResponseObservation.status` is
1469+
// contracted as "the status of the response as sent", so the
1470+
// observer is owed that status — read off the SAME rule the
1471+
// seam renders from, never a second copy of it. The throw is
1472+
// re-raised untouched: observing is not handling.
1473+
status = declaredEnvelopeForThrow(err)?.status ?? 500;
1474+
throw err;
13211475
} finally {
13221476
// An unrouted request executes only this adapter's own
13231477
// `use('*')` seams, so after `next()` `routePath(c)` reports

0 commit comments

Comments
 (0)