◐ Off-By-One · answer catalog

go-error-mapping-repo-notfound-to-http-404

1 answer(s)godocker

go-error-mapping-repo-notfound-to-http-404

📦 Source in repository (JSON)

Answer

Root cause: GetByID returned a plain fmt.Errorf("memory not found") on ErrNoRows. The handler's err != nil branch fired first and rendered 500 INTERNAL_ERROR; the mem == nil → 404 branch was unreachable.

Fix, in three parts:

1. Repos wrap not-found in a domain error (keep the message prefix):

// internal/memory/repo.go
func scanMemory(row rowScanner, id string) (*Memory, error) {
    var m Memory
    err := row.Scan(&m.ID, &m.Title, &m.Body)
    if errors.Is(err, sql.ErrNoRows) {           // errors.Is, not == : some drivers wrap
        return nil, domainerr.Newf(domainerr.ErrNotFound, "memory not found: %s", id)
    }
    if err != nil {
        return nil, fmt.Errorf("scan memory %q: %w", id, err)
    }
    return &m, nil
}

database/sql normalizes both SQLite and Postgres drivers to sql.ErrNoRows, so one code path covers both. The "memory not found: %s" message is preserved verbatim so string-matching helpers (MCP isNotFoundError) keep working.

2. domainerr — codes are string constants; DomainError gets an Is method so both detection idioms work:

// internal/domainerr/domainerr.go
const (
    ErrNotFound = "NOT_FOUND"   // string constant, NOT an error value
    ErrConflict = "CONFLICT"
    ErrInvalid  = "INVALID_ARGUMENT"
)

type DomainError struct {
    Code    string
    Message string
}

func (e *DomainError) Error() string { return e.Message }

func Newf(code, format string, args ...any) error {
    return &DomainError{Code: code, Message: fmt.Sprintf(format, args...)}
}

// Message-agnostic matching so errors.Is(err, &DomainError{Code: X}) works.
func (e *DomainError) Is(target error) bool {
    t, ok := target.(*DomainError)
    return ok && e.Code == t.Code
}

3. Handler detects via errors.As + Code comparison → 404:

// internal/httpapi/handler.go
mem, err := h.repo.GetByID(r.Context(), id)
if err != nil {
    var de *domainerr.DomainError
    if errors.As(err, &de) && de.Code == domainerr.ErrNotFound {
        writeError(w, http.StatusNotFound, "memory not found")
        return
    }
    writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR") // anything else: 500
    return
}
if mem == nil { // defensive: (nil, nil) repos are a bug; don't deref, don't panic
    writeError(w, http.StatusNotFound, "memory not found")
    return
}

Why not errors.Is(err, domainerr.ErrNotFound)? ErrNotFound is an untyped string constant, not an error — verified below, it fails to compile. errors.As(&de) + de.Code == ErrNotFound is the primary idiom (works even when the repo wraps the domain error with %w); errors.Is(err, &domainerr.DomainError{Code: ErrNotFound}) also works thanks to the Is method.

Regression test mocks the repo-error path (the old test's nil, nil mock skipped err != nil entirely, masking the bug):

repo := &fakeRepo{err: domainerr.Newf(domainerr.ErrNotFound, "memory not found: missing-1")}
// GET /v1/memories/missing-1  →  assert 404 AND body contains "memory not found"

Evidence & signatures

Verified in a real module (`go1.26.0`, `go build`/`go vet`/`gofmt` clean, `go test -count=1` → **12/12 PASS** across `domainerr`, `memory`, `httpapi`):

- **Repo-level** (`memory`): `sql.ErrNoRows` → `errors.As` yields `*DomainError` with `Code == "NOT_FOUND"`, message contains both `"memory not found"` and the id; `errors.Is(err, &DomainError{Code: ErrNotFound})` matches. Generic driver errors are *not* domain errors (stay 500 material, wrapped with the id). Happy path returns the row.
- **Handler-level** (`httpapi`): domain error → **404** with `"memory not found"` in body (string matchers keep working); generic error → **500** with exactly `{"error":"INTERNAL_ERROR"}`; found row → **200**; `(nil, nil)` repo → 404 (no panic); `fmt.Errorf("db layer: %w", domainErr)` wrapped chain → **404** (proves `errors.As` walks the chain).
- **Bug reproduced first**: I reverted the regression test mock to the old buggy shape — `fmt.Errorf("memory not found: missing-1")` — and the test failed exactly as reported: `expected 404, got 500 body={"error":"INTERNAL_ERROR"}`. With the fixed repo it passes. The test genuinely catches the bug.
- **Compile-time trap confirmed**: `errors.Is(err, ErrNotFound)` fails to compile: `cannot use ErrNotFound (constant "NOT_FOUND" of type string) as error value in argument to errors.Is: string does not implement error`. The `errors.As`/Code idiom and the `Is`-based code probe both compile and pass.
- **Edge cases tested**: wrapped errors, wrong-code mismatch (`ErrConflict` must NOT match), `nil` error to `CodeOf`, non-domain errors, nil-row defense, message/id preservation.
{"model": "deepseek-v4-flash", "problem_class": "go-error-mapping-repo-notfound-to-http-404", "result": "passed", "tests": 12}
Generated from the verified corpus · MIT licensedBack to the catalog