Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,16 @@ func (c *AuthController) Register(ctx *gin.Context) {
return // Transaction auto-rolls back on non-success status
}

ctx.JSON(201, user) // Transaction auto-commits on 200/201/303
ctx.JSON(201, user) // Transaction auto-commits on any 2xx/3xx status
}
```

**Auto-commit/rollback behavior:**
- Commits on status codes: 200, 201, 303
- Commits on any 2xx or 3xx status (`200 <= status < 400`)
- Rolls back on all other status codes or panics

**Response buffering:** `Transactional` holds the handler's response in memory (`responseBuffer` in `buffer_response.go`) and only releases it after `Commit()` succeeds; a failed commit answers 500 with an empty body instead of the 2xx the handler rendered, and commit hooks run before the release (each hook is isolated by `runCommitHook`, so a panicking hook is recorded on `c.Errors` without cancelling the others or the response). Handlers on transactional routes therefore must not stream, flush or hijack.

**Body buffering:** `Transactional` reads the request body into memory (cap `maxTransactionalBodyBytes`, 8MB) before it calls `db.DB.Begin()`, answering 413/408/400 without a transaction when that fails; the handler's later bind is served from memory. This is what keeps a slow-drip body from holding the single SQLite main-DB connection, and it covers every transactional route without each one remembering a special middleware. Routes that want a tighter cap put `middleware.BufferAuthBody` (64KB) in front of `Transactional`, which then skips the body it finds already buffered; the anonymous auth endpoints do this. The self-transacting OAuth routes (`/auth/device/*`, `/auth/token`, `/auth/logout`) use `BufferAuthBody` on its own for the same cap. `UseAppAuth` caps every dashboard route's body at the same 8MB with `MaxBytesReader`, so the non-transactional telemetry reads are bounded too. Every plain JSON bind site in the controllers answers through `middleware.RejectBindError(c, err, fallback)`, which maps `MaxBytesError` to 413 and the body guard's timeout to 408 with fixed messages and otherwise returns 400 with the fallback (the ingest routes use `middleware.RejectIngestBindError`, the same mapping except that the timeout becomes 503 + `Retry-After`, because OTLP exporters retry 503 but treat 408 as permanent; the Traceway SDKs re-queue a failed batch whatever the status); the guard itself swaps the raw deadline error (a `*net.OpError` naming the listener address) for `middleware.ErrBodyTimedOut` before it leaves `Read`, so no handler can echo the address.

**Preference:** For CRUD controller methods, always prefer using `middleware.Transactional` in the route + `db.GetTx(ctx)` in the controller over `pgdb.ExecuteTransaction`. The middleware approach keeps controllers flat, avoids nested closures, and follows the established pattern. (The tx getter lives in the `db` package: `db.GetTx(ctx)`, not `middleware.GetTx`.)
Expand Down Expand Up @@ -1245,19 +1247,26 @@ if err != nil {
traceway.CaptureException(fmt.Errorf("failed to read session recording (key=%s): %w", key, err))
}

// ALSO CORRECT - inside a handler or middleware, before the response is sent
if err != nil {
_ = c.Error(fmt.Errorf("failed to warm the project cache: %w", err))
}

// WRONG - Do not use log.Printf for errors
if err != nil {
log.Printf("Failed to read session recording (key=%s): %v", key, err)
}
```

Inside a handler or middleware with the response not yet sent, `c.Error(err)` is an equivalent channel: tracewaygin reports every `c.Errors` entry with the request's trace, and gin's Logger prints it even when monitoring is off, whereas `CaptureException` is a no-op until the SDK is initialised. `runCommitHook` uses it for a panicking commit hook.

**Validation error conventions:**
- `400 Bad Request`: Malformed requests, missing required params, type errors
- `422 Unprocessable Entity`: Business validation in form dialogs (name too long, duplicate name, required field empty). Return `c.JSON(422, gin.H{"error": "descriptive message"})`. The frontend `api.ts` extracts 422 error messages — dialogs catch and display them inline.

**Summary:**
- **Stopping errors** (abort the request): `c.AbortWithError(status, traceway.NewStackTraceErrorf("reason: %w", err))`
- **Non-stopping errors** (continue serving): `traceway.CaptureException(fmt.Errorf("reason: %w", err))`
- **Non-stopping errors** (continue serving): `traceway.CaptureException(fmt.Errorf("reason: %w", err))`, or `c.Error(err)` when a `*gin.Context` is in hand and the response has not been sent
- **Validation errors** (user-facing): `c.JSON(422, gin.H{"error": "message"})` for form validation
- **Always** wrap errors with `traceway.NewStackTraceErrorf` or `fmt.Errorf` using `%w` — never discard the original error

Expand Down
103 changes: 103 additions & 0 deletions backend/app/middleware/buffer_response.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package middleware

import (
"bufio"
"bytes"
"fmt"
"maps"
"net"
"net/http"

"github.com/gin-gonic/gin"
)

var errHijackUnderTransaction = fmt.Errorf("hijack is not supported on a transactional route: %w", http.ErrNotSupported)

// responseBuffer holds a handler's status and body until Transactional knows
// whether the transaction committed.
type responseBuffer struct {
gin.ResponseWriter
ctx *gin.Context
body bytes.Buffer
status int
written bool
headers http.Header
}

func bufferResponse(c *gin.Context) *responseBuffer {
buf := &responseBuffer{
ResponseWriter: c.Writer,
ctx: c,
status: c.Writer.Status(),
headers: c.Writer.Header().Clone(),
}
c.Writer = buf
return buf
}

func (b *responseBuffer) WriteHeader(code int) {
if code <= 0 || b.written {
return
}
b.status = code
}

// WriteHeaderNow locks the status the way gin's writer does; nothing reaches
// the wire until release.
func (b *responseBuffer) WriteHeaderNow() {
b.written = true
}

func (b *responseBuffer) Flush() {
b.WriteHeaderNow()
}

func (b *responseBuffer) Write(data []byte) (int, error) {
b.WriteHeaderNow()
return b.body.Write(data)
}

func (b *responseBuffer) WriteString(s string) (int, error) {
b.WriteHeaderNow()
return b.body.WriteString(s)
}

func (b *responseBuffer) Status() int {
return b.status
}

func (b *responseBuffer) Size() int {
if !b.written {
return -1
}
return b.body.Len()
}

func (b *responseBuffer) Written() bool {
return b.written
}

func (b *responseBuffer) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return nil, nil, errHijackUnderTransaction
}

// Unwrap keeps http.ResponseController reaching the connection from inside a
// handler (SetWriteDeadline and friends), as it does through gin's own writer.
func (b *responseBuffer) Unwrap() http.ResponseWriter {
return b.ResponseWriter
}

// discard drops the buffered response and restores the headers as 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 that replaces it.
func (b *responseBuffer) discard() {
clear(b.Header())
maps.Copy(b.Header(), b.headers)
b.ctx.Writer = b.ResponseWriter
}

func (b *responseBuffer) release() {
b.ctx.Writer = b.ResponseWriter
b.ResponseWriter.WriteHeader(b.status)
b.ResponseWriter.Write(b.body.Bytes())
}
36 changes: 30 additions & 6 deletions backend/app/middleware/transactional.middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,32 @@ import (
"github.com/tracewayapp/traceway/backend/app/db"

"github.com/gin-gonic/gin"
traceway "go.tracewayapp.com"
)

// Transactional opens a main-DB transaction for the request and commits it
// when the handler answers 2xx/3xx, rolling back otherwise. The handler's
// response is buffered and only released once Commit() has succeeded; a
// failed commit answers 500 with an empty body instead of the 2xx the
// handler rendered for rows that never persisted. Handlers under it must not
// stream, flush or hijack.
func Transactional(c *gin.Context) {
if !bufferRequestBody(c, maxTransactionalBodyBytes) {
return
}

txHandle, err := db.DB.Begin()

if err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
panic(err)
c.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("begin transaction: %w", err))
return
}

buf := bufferResponse(c)

defer func() {
if r := recover(); r != nil {
txHandle.Rollback()
buf.discard()
c.AbortWithStatus(http.StatusInternalServerError)
panic(r)
}
Expand All @@ -37,13 +46,17 @@ func Transactional(c *gin.Context) {

if status := c.Writer.Status(); status >= 200 && status < 400 {
if err := txHandle.Commit(); err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
panic(err)
buf.discard()
c.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("commit transaction: %w", err))
return
}
// Hooks run before the response leaves: the project cache must hold
// the row before the client can send its next request.
runCommitHooks(c)
} else {
txHandle.Rollback()
}
buf.release()
}

const commitHooksContextKey = "txCommitHooks"
Expand All @@ -63,6 +76,17 @@ func runCommitHooks(c *gin.Context) {
hooks, _ := c.Get(commitHooksContextKey)
fns, _ := hooks.([]func())
for _, fn := range fns {
fn()
runCommitHook(c, fn)
}
}

// The transaction is already committed, so a hook failure must neither cancel
// the remaining hooks nor turn a persisted request into a 500.
func runCommitHook(c *gin.Context, fn func()) {
defer func() {
if r := recover(); r != nil {
_ = c.Error(traceway.NewStackTraceErrorf("commit hook panicked: %v", r))
}
}()
fn()
}
Loading