affaan-m/ECC · info

user %s: %w

Error message

user %s: %w

What it means

A Go fmt.Errorf format string used inside UserRepository.FindByID to wrap the domain sentinel ErrNotFound with the queried id: fmt.Errorf("user %s: %w", id, ErrNotFound). This preserves the sentinel (so errors.Is(err, ErrNotFound) still works at the handler) while adding the id of the user that was not found.

Source

Thrown at skills/error-handling/SKILL.md:274

### Sentinel Errors and Error Wrapping

```go
package domain

import "errors"

// Sentinel errors for type-checking
var (
    ErrNotFound    = errors.New("not found")
    ErrUnauthorized = errors.New("unauthorized")
    ErrConflict     = errors.New("conflict")
)

// Wrap errors with context — never lose the original
func (r *UserRepository) FindByID(ctx context.Context, id string) (*User, error) {
    user, err := r.db.QueryRow(ctx, "SELECT * FROM users WHERE id = $1", id)
    if errors.Is(err, sql.ErrNoRows) {
        return nil, fmt.Errorf("user %s: %w", id, ErrNotFound)
    }
    if err != nil {
        return nil, fmt.Errorf("querying user %s: %w", id, err)
    }
    return user, nil
}

// At the handler level, unwrap to determine response
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    user, err := h.service.GetUser(r.Context(), chi.URLParam(r, "id"))
    if err != nil {
        switch {
        case errors.Is(err, domain.ErrNotFound):
            writeError(w, http.StatusNotFound, "not_found", err.Error())
        case errors.Is(err, domain.ErrUnauthorized):
            writeError(w, http.StatusForbidden, "forbidden", "Access denied")
        default:
            slog.Error("unexpected error", "err", err)

View on GitHub (pinned to 01e15490f0)

Solutions

  1. Always use %w when wrapping sentinels so errors.Is keeps working at higher layers.
  2. At the handler, branch on errors.Is(err, ErrNotFound) to return 404.
  3. Wrap consistently across all repositories so handlers can rely on the sentinel.
  4. If id is sensitive, redact it in the wrapped message or in logs.

Example fix

// before
return nil, fmt.Errorf("user %s: %w", id, ErrNotFound)

// after: handler-side unwrapping for the correct status
user, err := h.service.GetUser(ctx, id)
if err != nil {
    if errors.Is(err, domain.ErrNotFound) {
        return nil, &HTTPError{Code: 404, Message: "user not found"}
    }
    return nil, fmt.Errorf("get user: %w", err)
}
Defensive patterns

Strategy: try-catch

Validate before calling

null

Type guard

null

Try / catch

user, err := repo.FindByID(ctx, id)
if err != nil {
    if errors.Is(err, domain.ErrNotFound) {
        return nil, &HTTPError{Code: 404, Message: "user not found"}
    }
    return nil, fmt.Errorf("get user %s: %w", id, err)
}

Prevention

When it happens

Trigger: FindByID is called with an id; the underlying query returns sql.ErrNoRows; the code converts that to ErrNotFound wrapped with the id. The wrapped error propagates up to the handler which unwraps via errors.Is to decide the HTTP response.

Common situations: Handler does not unwrap — returns 500 for a 404 situation; using %v instead of %w breaks the handler's errors.Is check; the id is logged in clear text (PII concern); inconsistent wrapping across repositories (some wrap, some return raw sql.ErrNoRows).

Related errors


AI-assisted analysis of affaan-m/ECC@01e15490f0 (2026-08-13). Data as JSON: /api/errors/4ab1406535543e11. Report an issue: GitHub.