You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit a75ddba
Browse filesBrowse the repository at this point in the historyBrowse files
fix(rest): a hook's onError abort is no longer logged as an unhandled error (#27690)
<!-- claude-projects-attribution: {"github_login":"os-sales"} -->
_Requested by **Sales** · [project
thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuPMZ8BHWtPf6nBTp51qSM64)_
Refs #27508 (log half)
Clause-②: no
Metadata-protocol: none
Skills-docs: none
## 维护者速读
- **改了什么**:hook 以 `onError: 'abort'`(默认)抛出业务错误中止写入时,服务端不再把它记成 `[REST]
Unhandled error`;线上应答(400、正文、信封)一个字节都不变。
- **为什么改**:这是路由已经答复给调用方的拒绝,不是故障;运维在 error
日志里分不清「有意中止」和「真崩溃」。分诊裁定只做日志这一半,错误码那一半另走 spec 车道。
- **风险与回滚**:只影响日志判定,不动应答;豁免只认「沙箱体抛出、非崩溃、未声明 code、4xx」的拒绝,崩溃和其他来源的无码 4xx
照旧记录。回滚即 revert 本 PR。
- **先看**:`packages/rest/src/error-response.ts` 的 `isSandboxBodyRefusal`
与 `logUnexpectedRouteError`
调用处;`packages/rest/src/rest-declared-client-refusal-log.test.ts` 新增的
describe 块。
- **两分钟验证**:`pnpm --filter @objectstack/rest exec vitest run
src/rest-declared-client-refusal-log.test.ts`,预期 16 条全绿(其中 hook 中止两扇门都无
Unhandled 行,三条对照照旧记录)。
- **需要你做的**:无,席位落地。
## What changes
- `packages/rest/src/error-response.ts`: a new predicate,
`isSandboxBodyRefusal`, joins the three reads `logUnexpectedRouteError`
already makes. It is true when all three hold:
1. the answer is in the 4xx band;
2. the error carries a sandboxed body's business sentence
(`sandboxBusinessMessage`: `innerMessage` set, and not a native-error
crash); and
3. the body declared no code (`resolveThrownHttpError(...).declaredCode`
is absent). This is read off the thrown error, not the answer, so a code
the door itself adds to an undeclared abort later does not make the
abort loud again.
- Nothing on the wire moves. `resolveErrorResponse`, `mapDataError` and
`packages/types` are untouched. The ADR-0112 ledger is not touched and
no code is minted; that half belongs to the spec lane.
- Operators keep a line for each abort: the BodyRunner already logs
every body refusal at `info` (`[BodyRunner] sandboxed hook refused`).
The error-level line removed here was the second line for one ordinary
refusal.
- Docblocks on `isExpectedRouteError` and `isDeclaredClientRefusal` that
said the sandbox unwrap's un-coded 400 "is still logged" now point at
the new read.
- Changeset: `@objectstack/rest` patch.
## Tests
- `packages/rest/src/rest-declared-client-refusal-log.test.ts`. The
standing control "an un-coded business-rule 400 (a sandboxed hook
refusal) logs" pinned the old verdict, so it moves into a new block that
pins what triage ruled. It goes through the real route handlers (`POST
/api/v1/data/:object` through `mapDataError` +
`logUnexpectedRouteError`, and `PUT /api/v1/meta/:type/:name` through
`handleRouteError`):
- a hook abort answers exactly `{ error: 'boom abort', object:
'qa_media' }` at 400 on the data door and `{ error: 'boom abort' }` at
400 on the meta door, both `toEqual`, and logs no `[REST] Unhandled
error` line;
- CONTROL: a genuine `TypeError` at the same door is a 500 and is
logged, checked by identity;
- CONTROL: an un-coded 400 from a producer that is not a sandboxed body
is still logged;
- CONTROL: a sandboxed body that threw an unregistered code is still
logged;
- the existing loud controls are unchanged, including the sandbox crash
that declared a 4xx envelope (500, logged) and the registered code on an
undeclared status.
-
`packages/qa/dogfood/test/object-hook-lifecycle-scratch.dogfood.test.ts`
acceptance[6] is the real door with a real QuickJS `SandboxError`: a
`beforeInsert` hook declared `onError: 'abort'` throws. It now also
asserts the exact body `{ error, object }` and that the request logs no
`[REST] Unhandled error` line.
<details><summary>Ablation 1: unit route pin (remove the call-site
exemption, then restore)</summary>
`scripts/ablation-replace.mjs`, with the anchor on the `&&
!isSandboxBodyRefusal(error, resolved)` call-site line, at head
`2e4954fd`:
```
ablation-replace: ok mutation landed: anchor 1 -> 0, blob 10b6ede -> 416dfa06aa9b
× POST /data/:object answers the same code-less 400 and prints no "[REST] Unhandled error" 13ms
× the handleRouteError door agrees: same answer it always gave, no unhandled line 3ms
AssertionError: expected [ [ '[REST] Unhandled error:', …(1) ] ] to have a length of +0 but got 1
Tests 2 failed | 14 passed (16)
ablation-replace: ok restored: blob == HEAD (10b6ede) and `git diff HEAD` is empty
```
Both tests fail only at the log assertion. Their status and exact-body
assertions come first and pass under the ablation, which is the
pre-change logic. So the wire answer is the same as before.
</details>
<details><summary>Ablation 2: real-door dogfood pin (mutate, rebuild
rest dist, preflight, run, restore, rebuild, preflight)</summary>
```
ablation-replace: ok mutation landed: anchor 1 -> 0, blob e019a04 -> fcf8c33d2a68
MUTATE-BUILD-EXIT=0
✓ dist/: marker absent from all 6 built files -- the artifact the suite consumes no longer carries it.
MUTATED-RUN-EXIT=1
× acceptance[6], negative[0]: a throwing beforeInsert under abort refuses the POST and lands no row 150ms
AssertionError: expected [ Array(1) ] to have a length of +0 but got 1
Tests 1 failed | 3 passed (4)
ablation-replace: ok restored: blob == HEAD (e019a04) and `git diff HEAD` is empty
RESTORE-BUILD-EXIT=0
✓ dist/: marker present in 2 built files
✓ tree: working tree clean against HEAD
```
This ran at `17713e7b`. The later commit `2e4954fd` changes only
condition 3 of the predicate, not the call-site anchor; ablation 1 was
re-run on it.
</details>
## Acceptance notes
- **Scope of the marker.** `SandboxError` carries no body kind, so a
sandboxed action's code-less business refusal answered at a data door is
quiet too. It is the same shape and the same answer. A message-prefix
read (`hook '…' threw:`) would tell the two apart, but that would be a
text heuristic, so it was not used.
- **Overlap with the code half.** The open draft #27667 (the spec-lane
card for the code half) edits the same test file. It changes the control
this PR moves (it asserts `HOOK_ABORTED` and "still logs"), so a text
conflict in `rest-declared-client-refusal-log.test.ts` is expected for
whichever PR lands second. The predicate reads the thrown error's code,
not the answer's, so a door-assigned `HOOK_ABORTED` keeps the abort
quiet. When that PR rebases, its "still logs" line should become
"quiet".
- The undeclared-code-under-a-catalogue case
(`undeclaredHookRefusalAtRun`) becomes a `SandboxError` with no
`innerMessage`. That is the sandbox's own fault (500), so it stays loud,
unchanged.
## Verification
All local readings below are at head `2e4954fd` unless noted.
<details><summary>Package tests, typecheck, lint</summary>
- `pnpm --filter @objectstack/rest test`: `Test Files 336 passed (336)`,
`Tests 6602 passed | 326 skipped (6928)`, exit 0.
- `pnpm --filter @objectstack/rest typecheck`: exit 0.
- `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2
test/object-hook-lifecycle-scratch.dogfood.test.ts`: `Tests 4 passed
(4)`, exit 0.
- eslint (`--no-inline-config`) on `packages/rest` and the dogfood test
file: exit 0.
</details>
<details><summary>Derived gate families</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 64 families at the final change set.
Each was run with its exit code captured before any pipe, and all 64
exited 0. The run was split across a container restart: the families not
yet run were re-run at the same head, `2e4954fd`.
`check:type-check-debt` was run whole under the verify lock after a
queue timeout gave it PREREQUISITE NOT MET (exit 3). Its re-run exited
0. `check:dual-build-cjs-loads` exited 0 once a full build had run,
after an earlier PREREQUISITE NOT MET (exit 3).
Reconciliation, `--ran`:
```
Run reconciliation — 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED (a DERIVED zero — all 64 recorded an exit code and none of them is 3).
```
The full dogfood battery is not derived: there is no `!` in the title
and no `(narrowing)` arm. The repo-wide `pnpm lint` and the rest of the
farm are left to CI.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(rest): a sandboxed hook's code-less `onError: 'abort'` refusal is no longer logged as `[REST] Unhandled error`
6
+
7
+
Clause-②: no
8
+
Metadata-protocol: none
9
+
10
+
A hook that aborts its write with a business `throw` (`onError: 'abort'`, the default) is a refusal the route already answered: the data door replies with the hook's own sentence at 400. The server still printed `[REST] Unhandled error: SandboxError` and a stack for every such abort, beside the BodyRunner's own `sandboxed hook refused` line, so operators reading the error log could not tell an intended abort from a crash.
11
+
12
+
The route log now recognises that refusal by the sandbox marker every door already reads (the body's unwrapped sentence, which a crash never has) on a 4xx answer from a body that threw no code, and stays quiet for it. Nothing changes on the wire: status, body and envelope are byte-identical. A sandbox crash, a plain un-coded 4xx from any other producer, and a sandboxed body that threw a code (judged as a coded refusal, as before) are still logged.
0 commit comments