go-error-handling — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited go-error-handling (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Errors in Go are values. Treat them as part of the API: choose a strategy per failure mode, propagate with wrapping, inspect with errors.Is/As, and handle each error exactly once.
%w to preserve identity; %v to deliberately hide an unstable type.Pick the simplest strategy that meets the caller's needs:
| Strategy | When to use | Example |
|---|---|---|
| Opaque error (default) | Caller only needs to know something failed | errors.New("invalid input") |
| Sentinel error | Caller needs to test for a specific named condition | io.EOF, sql.ErrNoRows |
| Typed error | Caller needs structured fields (path, code, retry-after) | *os.PathError, *url.Error |
| Joined errors | A single operation produced several independent failures | errors.Join(errA, errB) |
Read references/strategy-decision.md when the caller's needs are unclear or when migrating between strategies without breaking callers.
fmt.Errorf("write %s: %w", path, err) reads as one sentence."open config: permission denied" beats "failed to open file".// Opaque — the caller only checks != nil
return errors.New("invalid character in token")
// Sentinel — exported, package-level, named ErrXxx
var ErrNotFound = errors.New("user: not found")
// Typed — when callers need structured fields
type ValidationError struct {
Field string
Rule string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: %s violates %s", e.Field, e.Rule)
}%w to add context while preserving identityif err := db.Get(id); err != nil {
return fmt.Errorf("loading user %d: %w", id, err)
}errors.Is (identity) and errors.As (type)if errors.Is(err, sql.ErrNoRows) { /* handled */ }
var ve *ValidationError
if errors.As(err, &ve) {
return reply.BadRequest(ve.Field)
}Never compare error strings (err.Error() == "...") — strings are not stable API.
errs := errors.Join(
validate(name),
validate(email),
validate(password),
)
if errs != nil {
return errs // errors.Is/As walks both branches
}Read references/wrapping-vs-shadowing.md when deciding between%w(expose) and%v(hide), or when wrapping would leak an implementation detail.
// Bad: caller will log it again, producing duplicate lines
if err := svc.Do(ctx); err != nil {
slog.ErrorContext(ctx, "svc.Do failed", "err", err)
return err
}
// Good: log only at the boundary that decides the request is done
if err := svc.Do(ctx); err != nil {
return fmt.Errorf("doing svc work: %w", err)
}The HTTP handler / job runner / main is the only layer that logs.
// Bad
if err == nil {
if x, ok := f(); ok {
return x, nil
}
}
return zero, err
// Good
if err != nil {
return zero, err
}
x, ok := f()
if !ok {
return zero, errSomething
}
return x, nilpanic is for programmer errors (impossible states) and package initialization. It is never the right way to return a normal failure.
// Acceptable: invariants the type guarantees
func (q *Queue) MustEnqueue(v T) { if err := q.Enqueue(v); err != nil { panic(err) } }
// Acceptable: recover at the goroutine boundary so one bad request cannot kill the server
defer func() {
if r := recover(); r != nil {
log.Error("panic recovered", "value", r, "stack", debug.Stack())
http.Error(w, "internal error", 500)
}
}()Do not use recover to convert panics into errors as normal control flow.
For custom error types, implement Unwrap() error (or Unwrap() []error in Go 1.20+) so errors.Is/As can reach the cause. See references/wrapping-vs-shadowing.md.
| Anti-pattern | Why it hurts | Do this instead |
|---|---|---|
return errors.New(err.Error()) | Drops identity; errors.Is breaks | return fmt.Errorf("ctx: %w", err) |
if err.Error() == "EOF" | String matching against unstable text | errors.Is(err, io.EOF) |
_ = doThing() | Silently swallows failures | Handle, log at boundary, or document why |
Returning *MyError (concrete pointer) | Typed-nil trap; non-nil interface | Return error (see references/typed-nil-trap.md) |
| Logging then returning the same error | Duplicate log lines, no single source of truth | Log only at the top boundary |
| Wrapping at every layer with no new info | "a: b: c: d: real error" chains | Drop the wrap and return err |
Before finishing an error-handling change:
err.Error() string comparisons%w (or %v is intentional and commented)error interface, not concrete typesvar ErrXxx = errors.New(...)%w vs %v decisions*MyErr breaks == nil~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.