go-claim-no-row-200-semantics
Root cause: Store.ClaimCredits scanned the credits ledger with QueryRow(...).Scan(&balance) and returned any error verbatim — so pgx.ErrNoRows (publisher exists but has no ledger row → "no-credits domain") bubbled up raw and the handler turned every store error into HTTP 500. Spec 26 §5.3 defines that state as a legitimate outcome: nothing to claim → 200 with claimed=0. Unknown publishers were indistinguishable from that case, so they also 500'd instead of 404'ing.
Fix 1 — store (store.go): map pgx.ErrNoRows to (0, nil) and add the ErrPublisherNotFound sentinel, checked via a publisher-existence precondition:
var ErrPublisherNotFound = errors.New("publisher not found")
func (s *Store) ClaimCredits(ctx context.Context, publisherID string, amount int) (int, error) {
if amount <= 0 { // still validates publisher for a meaningful 404
if _, err := s.claim(ctx, publisherID, 0); err != nil { return 0, err }
return 0, nil
}
return s.claim(ctx, publisherID, amount)
}
func (s *Store) claim(ctx context.Context, publisherID string, amount int) (int, error) {
// 1. Unknown publisher -> sentinel (handler 404), never "zero credits"
var exists bool
if err := s.db.QueryRow(ctx, publisherExistsQuery, publisherID).Scan(&exists); err != nil {
return 0, fmt.Errorf("check publisher %q: %w", publisherID, err)
}
if !exists {
return 0, ErrPublisherNotFound
}
// 2. THE FIX: no ledger row == zero available credits == claim 0, not an error
var balance int
if err := s.db.QueryRow(ctx, ledgerBalanceQuery, publisherID).Scan(&balance); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil // no ledger row => claim 0, idempotent 200 (spec 26 §5.3)
}
return 0, fmt.Errorf("load ledger for %q: %w", publisherID, err)
}
// 3. Atomic debit, clamped to available balance
claim := min(balance, amount)
if claim == 0 { return 0, nil }
var remaining int
if err := s.db.QueryRow(ctx, debitQuery, publisherID, claim).Scan(&remaining); err != nil {
if errors.Is(err, pgx.ErrNoRows) { return 0, nil } // row vanished concurrently
return 0, fmt.Errorf("debit ledger for %q: %w", publisherID, err)
}
return claim, nil
}
Fix 2 — handler (handler.go): explicit status mapping — sentinel → 404, any other error → 500, success → 200 with {"claimed":N}:
claimed, err := h.store.ClaimCredits(r.Context(), publisherID, amount)
switch {
case errors.Is(err, ErrPublisherNotFound):
http.Error(w, `{"error":"publisher not found"}`, http.StatusNotFound)
case err != nil:
log.Printf("claim %q: %v", publisherID, err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
default:
writeJSON(w, http.StatusOK, map[string]int{"claimed": claimed}) // 200, incl. claimed=0
}
Regression proof (legacy.go + regression_test.go): the pre-fix store/handler are preserved verbatim (legacyStore.claim returns fmt.Errorf(...: %w", err) for the missing row; legacyHandleClaim 500s on any error). Regression tests drive old and new stacks against identical mocked DB states and assert the delta:
// Same DB state (publisher exists, ledger row missing -> pgx.ErrNoRows):
rrNew := ...NewHandler(NewStore(mock))... // -> 200, body {"claimed":0}
rrOld := ...legacyHandler... // -> 500 (the bug being fixed)
// Unknown publisher: new -> 404 via ErrPublisherNotFound; old -> 500 (no sentinel existed)
Built a runnable reproduction at `/tmp/claimfix` (pgx v5 + pgxmock v4) since the codebase wasn't shipped in the environment. Verification:
- **19/19 tests pass** (`go test ./...`), `go vet` and `gofmt` clean, full suite green under `go test -race -count=1`.
- **Mutation test:** reintroduced the raw `pgx.ErrNoRows` leak in the store → the 4 no-credits regression/e2e tests immediately failed; restored the fix → suite green again. This proves the tests actually pin the fix and would catch a revert.
Edge cases tested (18 store/handler/regression + 1 e2e):
- No-credits domain → store `(0, nil)`, HTTP **200 `{"claimed":0}`**; also asserted no `UPDATE` is issued (no stray debit).
- Missing ledger row is **not** misclassified as `ErrPublisherNotFound` (keeps 404 semantics honest).
- Unknown publisher → store `ErrPublisherNotFound` → HTTP **404** with `{"error":"publisher not found"}`.
- Partial claim → `min(balance, amount)` debited atomically → 200 `{"claimed":3}`.
- Over-claim clamps to available balance; zero-balance skips the debit.
- Genuine DB failure (e.g. connection refused) still wrapped → HTTP **500** (fix doesn't swallow real errors; `errors.Is(err, pgx.ErrNoRows)` is false).
- `amount<=0` still validates publisher (404 for ghosts); malformed amount → 400; wrong method → 404.
- Legacy code asserted to 500 on both no-credits and unknown-publisher states (documents the bug), new code 200/404 respectively.{"model": "deepseek-v4-flash", "problem_class": "go-claim-no-row-200-semantics", "result": "passed", "tests": 19}