Skip to content

Commit 7b3c366

Browse files
committed
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>
1 parent e4175a0 commit 7b3c366

2 files changed

Lines changed: 28 additions & 21 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/raw-mount-declared-envelope.test.ts

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -77,17 +77,17 @@ function declared(status: number, code: string, message: string) {
7777
*/
7878
function fourDoors(logger?: Logger): HonoHttpServer {
7979
const s = server(logger);
80-
const envelopeThrow = () => {
81-
throw declared(503, 'SERVICE_UNAVAILABLE', 'The authorization store could not be read.');
82-
};
83-
const plainThrow = () => {
84-
throw new Error('connect ECONNREFUSED 127.0.0.1:5432');
85-
};
86-
87-
s.getRawApp().get('/raw/envelope', () => { envelopeThrow(); });
88-
s.getRawApp().get('/raw/plain', () => { plainThrow(); });
89-
s.get('/wrapped/envelope', async () => { envelopeThrow(); });
90-
s.get('/wrapped/plain', async () => { plainThrow(); });
80+
// The two throws are built by a factory and thrown AT each door, rather
81+
// than thrown inside a shared helper: a Hono handler must not return
82+
// `void`, and only a body whose statement IS the `throw` infers `never`.
83+
const envelopeError = () =>
84+
declared(503, 'SERVICE_UNAVAILABLE', 'The authorization store could not be read.');
85+
const plainError = () => new Error('connect ECONNREFUSED 127.0.0.1:5432');
86+
87+
s.getRawApp().get('/raw/envelope', () => { throw envelopeError(); });
88+
s.getRawApp().get('/raw/plain', () => { throw plainError(); });
89+
s.get('/wrapped/envelope', async () => { throw envelopeError(); });
90+
s.get('/wrapped/plain', async () => { throw plainError(); });
9191
return s;
9292
}
9393

@@ -181,14 +181,13 @@ describe('the raw door reads the ONE rule, not a second one', () => {
181181
const s = server();
182182
// `plugin-approvals`' lifecycle hooks and `metadata-protocol` throw
183183
// `statusCode`; both spellings are produced in this repo.
184-
const thrower = () => {
185-
throw Object.assign(new Error('locked by another process'), {
184+
const conflict = () =>
185+
Object.assign(new Error('locked by another process'), {
186186
statusCode: 409,
187187
code: 'LOCK_CONFLICT',
188188
});
189-
};
190-
s.getRawApp().get('/raw/conflict', () => { thrower(); });
191-
s.get('/wrapped/conflict', async () => { thrower(); });
189+
s.getRawApp().get('/raw/conflict', () => { throw conflict(); });
190+
s.get('/wrapped/conflict', async () => { throw conflict(); });
192191

193192
const raw = await call(s, '/raw/conflict');
194193
const wrapped = await call(s, '/wrapped/conflict');
@@ -204,11 +203,10 @@ describe('the raw door reads the ONE rule, not a second one', () => {
204203
// declaration (#16545 recorded this as its widest limb), so a bare
205204
// ValidationError answers 400/VALIDATION_FAILED at BOTH doors rather
206205
// than the raw door inventing its own rule.
207-
const thrower = () => {
208-
throw Object.assign(new Error('name is required'), { name: 'ValidationError' });
209-
};
210-
s.getRawApp().get('/raw/invalid', () => { thrower(); });
211-
s.get('/wrapped/invalid', async () => { thrower(); });
206+
const invalid = () =>
207+
Object.assign(new Error('name is required'), { name: 'ValidationError' });
208+
s.getRawApp().get('/raw/invalid', () => { throw invalid(); });
209+
s.get('/wrapped/invalid', async () => { throw invalid(); });
212210

213211
const raw = await call(s, '/raw/invalid');
214212
const wrapped = await call(s, '/wrapped/invalid');

0 commit comments

Comments
 (0)