Skip to content

middleware: buffer the response until Commit succeeds; isolate commit hooks - #365

Open
FrameAutomata wants to merge 1 commit into
mainfrom
transactional-buffered-response
Open

middleware: buffer the response until Commit succeeds; isolate commit hooks#365
FrameAutomata wants to merge 1 commit into
mainfrom
transactional-buffered-response

Conversation

@FrameAutomata

Copy link
Copy Markdown
Collaborator

Closes #364

What

middleware.Transactional decided whether to commit only after the handler had already flushed its response, so a failed Commit() (SQLite database is locked, a full disk, a dropped PostgreSQL connection before COMMIT) still delivered the handler's 2xx for rows that were never persisted. POST /api/register was the worst case: the browser stored a JWT and a project token for a user, org and project that did not exist. The recover() path had the same flaw for a handler that panicked after writing.

Separately, runCommitHooks ran OnCommit callbacks with no per-hook recover, so one panicking hook skipped the rest and turned a request whose data was committed into a logged panic.

How

  • backend/app/middleware/buffer_response.go (new): responseBuffer replaces c.Writer for the handler chain and records the status and body in memory, mirroring gin's own responseWriter bookkeeping (Status/Size/Written, status locked once written). Unwrap() keeps http.ResponseController reaching the connection. Hijack returns an error.
  • Transactional: installs the buffer after Begin(). On a successful commit it runs the hooks and then releases the buffer with one status and one write. On a failed commit it discards the buffer, restores the headers to what they were before the handler ran (so a Content-Type, Location or Set-Cookie meant for the discarded response does not ride out on the 500), and answers through c.AbortWithError(500, traceway.NewStackTraceErrorf(...)), which lands now because nothing has reached the wire yet. A handler panic after writing discards the same way before re-panicking. A Begin() failure now also goes through AbortWithError instead of panic.
  • Hooks: each OnCommit callback runs under its own recover; a panic is recorded via c.Error (reported by tracewaygin with the request's trace and printed by gin's Logger even when monitoring is off, whereas traceway.CaptureException is a no-op until the SDK is initialised) and the remaining hooks and the response still go out. Hooks run before the release so the project cache holds the row before the client can send its next request.
  • Tests (transactional_test.go): a driver.Connector wrapping the real modernc sqlite driver whose transactions fail to commit on demand (rolling back for real, so the single in-memory connection is not discarded and a "0 rows" assertion cannot pass vacuously). Cases: failed commit → 500, empty body, handler headers stripped, outer middleware header kept, row absent, hook not run, one c.Errors entry; successful commit for JSON, redirect, bare c.Status(204) and c.JSON(204, nil) with the hook observed running before the first header write; panic after write → 500 and rollback; panicking hook → second hook runs, 201 delivered; Unwrap keeps http.ResponseController working. Each case was checked to fail when its guard is removed.
  • CLAUDE.md: the Transactional section documents the buffering (handlers under it must not stream or hijack) and now states the real commit condition (any 2xx/3xx, not "200, 201, 303"); the error-handling section notes when c.Error is the right non-stopping channel.

Verification

From nix develop .#backend, in backend/: gofmt -l, go vet, go test -race -count=1 ./..., and go build under the default, transactional_pg telemetry_ch and telemetry_duckdb tag sets all pass. The new test file is tagged !transactional_pg only, so CI's telemetry_duckdb run covers it too.

Follow-ups (not in this PR)

  • OnCommit on a route without Transactional silently discards its callback (listed in Transactional: a failed Commit still returns the handler's 2xx, and one panicking commit hook silently skips the rest #364 under "related, not filed here").
  • Flush() under Transactional is a silent no-op by design. Nothing on the current transactional routes calls it, but making it loud (an error on c.Errors, or a panic the middleware turns into rollback + 500) would make the "no streaming" rule self-enforcing.
  • release() knows the exact body length and could set Content-Length so bodies over 2KB stop going out chunked. Not a regression (gin never set it either), so left out of a bug-fix PR.

Conflicts

#363 rewrites the OnCommit doc comment in the same file; this PR leaves that comment byte-identical and edits runCommitHooks directly below it, so whichever lands second may see a trivial adjacent-hunk conflict.

🤖 Generated with Claude Code

… hooks

Transactional decided whether to commit only after the handler had
already flushed its response, so a failed Commit() (SQLite "database is
locked", a full disk, a dropped Postgres connection) still delivered the
handler's 2xx for rows that were never persisted; /api/register handed
the browser a JWT for a user that did not exist. The recover path had
the same flaw for a handler that panicked after writing.

The middleware now swaps c.Writer for a responseBuffer that records the
status and body in memory and releases them only after Commit()
succeeds. A failed commit discards the buffer, restores the headers as
they were before the handler ran, and answers 500 with an empty body
through c.AbortWithError so the failure is logged and reported.

Commit hooks now run one at a time under their own recover: the
transaction is already committed, so a panicking hook is recorded on
c.Errors and the remaining hooks and the response still go out.

Closes #364

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@FrameAutomata FrameAutomata added the ci Run CI on this PR (remove and re-add to re-validate after a push) label Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci Run CI on this PR (remove and re-add to re-validate after a push)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Transactional: a failed Commit still returns the handler's 2xx, and one panicking commit hook silently skips the rest

1 participant