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
- Always use %w when wrapping sentinels so errors.Is keeps working at higher layers.
- At the handler, branch on errors.Is(err, ErrNotFound) to return 404.
- Wrap consistently across all repositories so handlers can rely on the sentinel.
- 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
- Always use %w when wrapping a sentinel so errors.Is still matches.
- Unwrap consistently at the handler to map to the correct status code.
- Wrap across all repositories so handlers can rely on the sentinel.
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.