Recipes

Error handling patterns

This recipe covers the error handling conventions used throughout a Stanza app — how handlers return errors, which HTTP status codes to use, and the Go patterns that keep error handling consistent and safe.


Error response format

All error responses use a consistent JSON structure:

{"error": "human-readable message"}

For validation errors, field-level details are included:

{
  "error": "validation failed",
  "fields": {
    "email": "must be a valid email address",
    "password": "must be at least 8 characters"
  }
}

The framework provides three helpers for writing error responses:

// Simple error — single message
http.WriteError(w, http.StatusNotFound, "user not found")

// Internal server error — logs err and writes 500
http.WriteServerError(w, r, "failed to list users", err)

// Validation error — per-field messages (422)
v := validate.Fields(
    validate.Required("email", req.Email),
    validate.Email("email", req.Email),
)
if v.HasErrors() {
    v.WriteError(w)
    return
}

Status code guide

SituationStatusCodeExample message
Malformed JSON, bad path param400StatusBadRequest"invalid request body"
Validation failure422StatusUnprocessableEntity"validation failed" (+ fields)
Missing or invalid credentials401StatusUnauthorized"authentication required"
Valid credentials, wrong scope403StatusForbidden"insufficient permissions"
Resource doesn't exist404StatusNotFound"user not found"
Unique constraint violation409StatusConflict"email already exists"
Expired/removed resource410StatusGone"paste has expired"
Too many requests429StatusTooManyRequests"too many requests"
Database or system failure500StatusInternalServerError"failed to create user"
Upstream API error502StatusBadGateway"AI service error (HTTP 500)"

Invalid input (400)

Return 400 when the request can't even be parsed — malformed JSON, unparseable path parameters, or body too large:

// Bad JSON body — BindJSON writes 400 and returns false on failure
var req createRequest
if !http.BindJSON(w, r, &req) {
    return
}

// Bad path parameter — writes 400 and returns false if invalid
id, ok := http.PathParamInt64(w, r, "id")
if !ok {
    return
}

The distinction from 422: 400 means the request is structurally broken (can't decode). 422 means the structure is fine but the values are wrong (email is empty, password too short).


Validation (422)

Use pkg/validate for field-level validation. It collects errors across all fields and returns them in one response:

v := validate.Fields(
    validate.Required("name", req.Name),
    validate.MaxLen("name", req.Name, 255),
    validate.Required("email", req.Email),
    validate.Email("email", req.Email),
    validate.MinLen("password", req.Password, 8),
    validate.OneOf("role", req.Role, "admin", "editor", "viewer"),
    validate.Positive("age", req.Age),
    validate.InRange("priority", req.Priority, 1, 5),
)
if v.HasErrors() {
    v.WriteError(w) // 422 with per-field errors
    return
}

For custom validation logic, use Check:

v := validate.Fields(
    validate.Required("start_date", req.StartDate),
    validate.Required("end_date", req.EndDate),
    validate.Check("end_date", endTime.After(startTime), "must be after start date"),
)

Available validators

ValidatorMessage
Required(field, value)"is required"
MinLen(field, value, min)"must be at least N characters"
MaxLen(field, value, max)"must be at most N characters"
Email(field, value)"must be a valid email address"
OneOf(field, value, allowed...)"must be one of: a, b, c"
Positive(field, value)"must be a positive number"
InRange(field, value, min, max)"must be between N and M"
Check(field, ok, message)Custom message

Not found (404)

Two patterns for detecting "not found" depending on the query type.

QueryRow: check Scan error

When fetching a single row, Scan returns sqlite.ErrNoRows if no row matches:

sql, args := sqlite.Select("id", "name", "email").
    From("users").
    Where("id = ?", id).
    WhereNull("deleted_at").
    Build()

if err := db.QueryRow(sql, args...).Scan(&u.ID, &u.Name, &u.Email); err != nil {
    http.WriteError(w, http.StatusNotFound, "user not found")
    return
}

Update/Delete: check affected rows

After an UPDATE or DELETE, check whether any rows were actually changed:

n, err := db.Update(sqlite.Update("users").
    Set("deleted_at", now).
    Where("id = ?", id).
    WhereNull("deleted_at"))
if err != nil {
    http.WriteServerError(w, r, "failed to delete user", err)
    return
}
if n == 0 {
    http.WriteError(w, http.StatusNotFound, "user not found")
    return
}

Conflicts (409)

Detect UNIQUE constraint violations by inspecting the error message from SQLite:

_, err := db.Insert(sqlite.Insert("users").
    Set("email", email).
    Set("name", name))
if err != nil {
    if strings.Contains(err.Error(), "UNIQUE constraint failed") {
        http.WriteError(w, http.StatusConflict, "email already exists")
        return
    }
    http.WriteServerError(w, r, "failed to create user", err)
    return
}

String matching

SQLite error messages include the constraint name (e.g., "UNIQUE constraint failed: users.email"). String matching is the correct approach here — SQLite's C API returns these as text, and the pkg/sqlite package surfaces them as wrapped errors.


Authentication and authorization (401/403)

Auth errors are handled by middleware, not by individual handlers. The framework's auth package provides two middleware:

// Validates JWT access token — returns 401 if missing, expired, or invalid
admin.Use(a.RequireAuth())

// Checks scope claim — returns 403 if the scope is missing
admin.Use(auth.RequireScope("admin"))
admin.Use(auth.RequireScope("admin:users"))

For login endpoints that validate credentials directly, use a generic message to prevent email enumeration:

if err := db.QueryRow(sql, args...).Scan(&id, &passwordHash); err != nil {
    // Don't reveal whether the email exists
    http.WriteError(w, http.StatusUnauthorized, "invalid credentials")
    return
}

For business logic authorization (not scope-based), use 400:

if !isActive {
    http.WriteError(w, http.StatusBadRequest, "cannot impersonate an inactive user")
    return
}

Internal errors (500)

Return 500 for failures the client can't fix — database errors, encoding failures, crypto failures. Use WriteServerError to log the real error and write the response in one call:

rows, err := db.Query(sql, args...)
if err != nil {
    http.WriteServerError(w, r, "failed to list users", err)
    return
}
defer rows.Close()

WriteServerError logs the error via the request-scoped logger (from RequestLogger middleware), so the log entry automatically includes request_id, path, and other request context. The client receives a generic {"error": "failed to list users"} response with status 500 — the real error is only in the logs.

The error message should be generic but descriptive — tell the client what operation failed without exposing internals. Never include the raw error in the response:

// Good: tells the client what failed, logs the real error
http.WriteServerError(w, r, "failed to create user", err)

// Bad: leaks internal details
http.WriteError(w, http.StatusInternalServerError, err.Error())

This replaces the manual log-then-write pattern that was previously needed for sensitive operations:

// Before — two steps, easy to forget the log
l := log.FromContext(r.Context())
l.Error("issue access token", log.String("error", err.Error()))
http.WriteError(w, http.StatusInternalServerError, "internal error")
return

// After — one call, error is always logged
http.WriteServerError(w, r, "internal error", err)
return

Panic recovery

The Recovery middleware catches panics and converts them to 500 responses:

router.Use(http.Recovery(func(v any, stack []byte) {
    logger.Error("panic recovered",
        log.Any("error", v),
        log.String("stack", string(stack)),
    )
}))

This prevents a single panic from crashing the process. The client gets {"error": "internal server error"} and the panic is logged with a full stack trace.

Recovery placement

Place Recovery as the last global middleware so it catches panics from all downstream middleware and handlers. If it's placed before RequestLogger, the logger won't see the 500 status.


Row iteration errors

Always check rows.Err() after a for rows.Next() loop. If Next() returns false due to an error (not just end-of-results), Err() returns that error:

rows, err := db.Query(sql, args...)
if err != nil {
    http.WriteServerError(w, r, "failed to list users", err)
    return
}
defer rows.Close()

users := make([]userJSON, 0)
for rows.Next() {
    var u userJSON
    if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil {
        http.WriteServerError(w, r, "failed to scan user", err)
        return
    }
    users = append(users, u)
}
if err := rows.Err(); err != nil {
    http.WriteServerError(w, r, "failed to iterate users", err)
    return
}

Silent error handling

Some errors are intentionally ignored — optional operations where failure shouldn't block the response:

// Webhook dispatch is best-effort
_ = wh.Dispatch(r.Context(), "user.created", map[string]any{"user_id": id})

// Count query failure defaults to 0 — the list still works
var total int
sql, args := sqlite.CountFrom(selectQ).Build()
_ = db.QueryRow(sql, args...).Scan(&total)

When ignoring errors, use _ explicitly to make the intent clear. Never swallow errors silently — either handle them, return them, or assign them to _.


The rules

  1. Handle errors once. Either log the error or return it — never both. Logging is handling. If you log and return, the caller logs it again.

  2. Return after writing an error. Always return after http.WriteError() or v.WriteError(). Without the return, the handler continues executing with invalid state.

  3. Generic messages for 500s. Tell the client what operation failed, not why. Use WriteServerError to log the real error and write the response in one call.

  4. Specific messages for 4xxs. The client can act on "email already exists" or "password must be at least 8 characters" — give them useful feedback.

  5. No error wrapping in handlers. Handlers are the end of the chain — they write a response. Wrapping (fmt.Errorf("...: %w", err)) is for library code that returns errors to callers.

  6. Close transient resources. Always defer rows.Close() after db.Query(). Always close r.Body in custom HTTP clients. Any type with Close() is a resource that must be released.

  7. Check rows.Err(). A for rows.Next() loop that exits normally might have encountered an error. Always check.

  8. Use _ for intentional ignores. Don't let errors vanish — make the decision explicit.

Previous
API versioning