Repository navigation
Preserve explicit API error responses - #480
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Accepted but transport-unsupported 4xx statuses are converted to generic 500 responses and lose the hook’s message, so status handling needs correction and regression coverage through response normalization.
1 open issue
What changed in this PR
This PR addresses #477 by preserving explicit client errors from record before-hooks, allowing API callers to receive meaningful rejection messages while retaining atomic rollback.
Changes:
- Preserve
ctx.failclient statuses and messages andctx.invalidfield details without changing the public dispatcher signature. - Expand regression coverage across create, update, and delete hooks, including rollback and response ownership.
- Document supported hook failures and generic fallback behavior.
| File | Description |
|---|---|
src/api/records.zig |
Preserve hook rejection details and add response, rollback, and ownership tests. |
docs/recipes.md |
Explain explicit client errors in the before-create recipe. |
docs/framework.md |
Document before-hook error responses and rollback guarantees. |
changelog.d/record-hook-client-errors.md |
Record the consumer-visible hook error fix. |
Review effort: Balanced
3d3e0b8 to
97284bd
Compare
valthon
left a comment
There was a problem hiding this comment.
Changes requested: please fix all 8 inline comments before merge. (Submitted as a comment because GitHub doesn't allow "request changes" on your own PR.)
Correctness (highest priority)
- Use-after-free:
ctx.fail/ctx.invalidslices are rendered after the hook frame is gone. - 5xx
ctx.failstatuses become a generic 400, unlike custom routes and auth lifecycle hooks. - Supported 4xx statuses with no dedicated code (418, 422, …) go out with
code: "internal". Fix this inerror_codes.forStatus. - An
error.Handledfrom a later hook can reuse an earlierctx.handledthe hook recovered from.
Docs
5. The §4 Semantics bullet in docs/framework.md still says before-hook rejections are always 400.
Design and cleanup
6. Reuse ctx.errorResponse(err) inside the hook scope (as fireAuthLifecycleBefore does) instead of the new failure out-parameter. This also fixes 1 and 2.
7. Move the inline 4xx/transport-status check into a named http helper shared with normalizeResponse.
8. The rollback test creates 60 TestEnvs. Cut it to a few representative cases per phase.
Note: this review is from reading the code. I did not build the branch or run its tests.
Generated by Claude Code
97284bd to
d90acf1
Compare
valthon
left a comment
There was a problem hiding this comment.
Re-review of d90acf1: all 8 earlier findings are addressed. There are 3 new comments, 2 of which should be fixed before merge.
| # | Earlier finding | Status |
|---|---|---|
| 1 | Use-after-free on hook-stack slices | ✅ ctx.fail/ctx.invalid copy into Ctx-owned memory (copyHandled); tests now overwrite the original buffers after the call |
| 2 | 5xx turned into 400 | ✅ preserved through http.isErrorStatus |
| 3 | Unmapped 4xx sent as internal |
✅ fixed in forStatus (but see the new comment there) |
| 4 | Old ctx.handled reused |
✅ clearHandled() runs before each handler; new dispatcher test |
| 5 | Contradictory Semantics docs | ✅ |
| 6 | Second error-mapping path | ✅ renders with ctx.errorResponse(err) while the Ctx is alive |
| 7 | Inline status check | ✅ http.isFinalStatus/isErrorStatus, shared with normalizeResponse |
| 8 | 60 TestEnvs |
✅ cut to 3 cases × 3 phases (nit: selected by index) |
New
- Fix before merge: the
forStatuschange also affects typed routes, routectx.failand auth-method failures. Unmapped 4xx auth-method failures now send their message to clients where they used to become a generic 500. Please record this in the changelog, confirm it is intended, and test it. - Fix before merge: a before-hook 5xx skips
onErrorand the Sentry backstop, unlike custom routes. - Nit: pick the reduced rollback-test cases by mode, not by array index.
Note: this re-review is from reading the code. I did not build the branch or run its tests.
Generated by Claude Code
There was a problem hiding this comment.
🔵 Needs a closer look
The transport update changes native polling and connection locking across Linux and macOS, requiring specialist concurrency review and still-unverified exact-head hosted ZigBase CI validation before approval.
No open issues 🎉
Review effort: Balanced
There was a problem hiding this comment.
🔵 Needs a closer look
The zap/facil.io repin changes concurrency-sensitive behavior outside the repository diff, requiring specialist confirmation of descriptor-generation and locking invariants across Linux and macOS.
No open issues 🎉
Review effort: Balanced
611eed2 to
9c17484
Compare
valthon
left a comment
There was a problem hiding this comment.
Branch untangled and history rewritten; 1 finding left open (inline), and the Zig changes need a compile/test pass.
What changed on this PR
- The branch had merged in commits from #481/#482: the
zaptransport re-pins,tests/adminfixture/port-reservation fixes, and an HTTP connection-reuse changelog fragment. Those are already in #481 and #482 and have been removed here. The previous head611eed2is preserved on branchcodex-does-too-much. - History rewritten into 2 commits (authored by @valthon):
4e5b546Map unmapped 4xx statuses to bad_request:forStatus, the auth-method redaction rule, thehttpstatus helpers +normalizeResponserefactor, the registry explanations, docs and changelog.9c17484Preserve record before-hook error responses:Ctx-owned failure copies, per-handler clearing, record-hook rendering,onErrorwith phasebefore_hookafter rollback and writer release, docs and changelog.
- Fixes from three adversarial review rounds are folded in:
- registry explanations (
internalandbad_request) and the guard comment updated; - changelog wording clarified;
- auth-method failures with a transport-unnamed status (e.g. 432) are now logged and redacted, where before their message was lost;
- before-hook incidents use the documented
before_hookphase instead ofrequest.
- registry explanations (
Not verified: these commits have NOT been compiled or tested. The session that rewrote them had no Zig toolchain. The PR description's validation section refers to the old head (611eed2) and no longer applies. Before merge:
- Run
zig build test --summary allon each of the 2 commits, plus the relevanttests/admincases. - Specifically confirm that the onError-after-release test's inspection of the writer mutex internals (
.state/.unlocked) compiles on Zig 0.16. - The auth-method unnamed-status redaction branch has no test, because it logs via
std.log.err, which the test runner treats as a failure. - Update the PR description: it still describes the removed transport/fixture changes.
Open: see the inline comment on before-hook incident reports.
Generated by Claude Code
There was a problem hiding this comment.
🟡 Changes recommended
Before-hook 5xx failures share a deduplication key that suppresses distinct error reports during the default 60-second window, while the surviving report omits the status and rejection message.
1 open issue
Review effort: Balanced
error_codes.forStatus derives the envelope code for a bare status (route ctx.fail, typed-route jsonError, auth-method Resolution.fail). Every status without a dedicated code fell back to `internal`, so a deliberate client error such as 422 reached clients labelled as a server fault. In auth-method dispatch, `internal` also triggers redaction: the method's 4xx message was logged and replaced with a generic 500. Unmapped 4xx statuses now map to `bad_request`; unmapped 5xx and other statuses stay `internal`. Auth-method failures with a named 4xx status therefore return their message to the anonymous caller with their own status. This is intentional: Resolution.fail and the framework guide now state that these messages are public explanations. Messages that cannot be sent as a client error stay operator-only. A 5xx, non-error, or transport-unnamed status (such as 432, which response normalization would replace with a 500 and whose message would otherwise be lost) is logged and redacted to the generic internal response. http.transportStatus, isFinalStatus and isErrorStatus centralize this status validity rule, and server response normalization now uses the same helpers. The `internal` and `bad_request` registry explanations (shown by `explain-code`) and the bare-ApiError guard comment now describe the new split; `bad_request` notes that retryability follows the HTTP status rather than the code. A dispatch test sends 401, 422 and 451 through response normalization and checks that 432, 500, 503 and 200 are redacted. The redaction log drops to warn under test, as webauthn and postgres TLS already do, because the test runner fails the build on any logged error. The allocator allowlist counts the test. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01HTAHrkfe1L2hfpLeP1CQAS
A record before-hook that rejected a write with ctx.fail or ctx.invalid always produced a generic 400 "Request rejected by a hook.", discarding the status, message and field details the hook chose. Custom routes already render those via Ctx.errorResponse. The HTTP create/update/delete paths now render the hook's failure with Ctx.errorResponse while the hook Ctx is still alive, for any transport- supported 4xx or 5xx status (http.isErrorStatus). Any other error, a bare error.Handled, or an unsupported status keeps the generic 400. The primary write and the hook's side-writes still roll back together. ctx.fail and ctx.invalid now copy the message and every field string into Ctx-owned memory, so hooks and routes may pass stack buffers. The record dispatcher clears any stored failure before each handler, so a failure a handler caught and recovered from cannot be reported by a later bare error.Handled. Allocation failure falls back to a generic internal error. Rendered 5xx failures invoke onError and the reporter backstop once, with phase `before_hook`, after rollback and writer release; the callback can therefore write through the writer. Deliberate 4xx failures stay silent. Each report's message is "<collection>: <status> <hook message>", built in the request arena while the Ctx owns the failure. Reporter dedup keys on (message, phase), so distinct failures inside one window are each delivered and a repeat of the same failure is still suppressed. The event's err is error.OutOfMemory when the response is the allocation fallback, and error.Handled otherwise. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01HTAHrkfe1L2hfpLeP1CQAS
9c17484 to
c8f4a4a
Compare
Follow-up on the pre-merge listAnswers to the pre-merge list in the 16:12 review, at
Commit |

Record before-create, before-update and before-delete hooks now keep the status and message of
ctx.fail, and the field details ofctx.invalid, in the standard API error envelope, for any status the HTTP transport supports. A rejection still rolls back the primary write and the hook's database side-writes. Unsupported statuses and arbitrary hook errors keep the generic 400.Ctxcopies the message and every validation string before the hook returns, so hooks may pass stack buffers. Generated dispatch clears a recovered failure before each handler.Hook 5xx responses, including allocation-failure fallbacks, reach
onErrorand the reporter once, with phasebefore_hook, after rollback and writer release, so the callback can write to the database. Each report's message is<collection>: <status> <hook message>, so reporter dedup keeps distinct failures apart. ItserrisOutOfMemoryfor the allocation fallback andHandledotherwise.Unmapped 4xx statuses use
bad_requestacross record hooks, typed and custom routes, and auth methods. Auth-method messages on a named 4xx status are intentionally client-facing. Messages on 5xx, non-error and transport-unnamed statuses (such as 432) stay operator-only: logged and redacted. Sharedhttpvalidity helpers keep hook mapping and response normalization aligned. The framework guide, recipes, method contract and changelog describe these semantics.The series is two commits: the shared status mapping, then the record-hook behaviour built on it.
Fixes #477.
Validation on Zig 0.16.0, against the tree of this head:
zig build test --summary allreturned 0 at both commits; 22/22 steps and 2269 of 2321 tests passed, 52 skipped.tests/adminwith-n 4: 364 passed, 154 skipped.test_docs_parity.pyandtest_two_factor_golfsim.pywere re-run in a checkout with git metadata and golfsim built: 11 passed.check-gating, allocator contracts, disabled agent CLI, error-code ledger, doctor ledger and changelog assembly passed.Handledreport text fails exactly the two reporter tests.🤖 Generated with Claude Code
https://claude.ai/code/session_01HTAHrkfe1L2hfpLeP1CQAS