go-error-mapping-repo-notfound-to-http-404
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"
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}