Inspecting Errors with Is and As
Once errors are wrapped, the error you receive is rarely the error that happened. It is a chain, and the interesting part is usually buried three layers down.
fmt.Errorf("starting service: %w", ...)
└── fmt.Errorf("loading config: %w", ...)
└── &os.PathError{Op: "open", Path: "app.yaml", Err: ...}
└── syscall.ENOENTerrors.Is and errors.As walk that chain for you. Between them they cover every question you will need to ask an error.
errors.Is asks "is this in the chain"
Use it to compare against a sentinel value.
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return defaultConfig(), nil // missing file is fine, use defaults
}
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}It walks the chain calling Unwrap and compares each link with ==, so it finds the target no matter how many layers wrapped it.
base := sql.ErrNoRows
wrapped := fmt.Errorf("querying user: %w", base)
deeper := fmt.Errorf("loading profile: %w", wrapped)
deeper == sql.ErrNoRows // false
errors.Is(deeper, sql.ErrNoRows) // trueNever compare errors with == in application code. It works right up until somebody adds a fmt.Errorf wrap somewhere in between, at which point the comparison silently starts returning false and your special case stops firing. errors.Is costs nothing extra and does not break.
The sentinels worth knowing
errors.Is(err, io.EOF) // the reader is finished
errors.Is(err, sql.ErrNoRows) // query matched nothing
errors.Is(err, os.ErrNotExist) // file or directory is missing
errors.Is(err, os.ErrPermission) // permission denied
errors.Is(err, context.Canceled) // caller gave up
errors.Is(err, context.DeadlineExceeded) // timed out
errors.Is(err, http.ErrServerClosed) // graceful shutdown, not a failureThat last one matters more than it looks. http.ListenAndServe returns ErrServerClosed when you shut it down deliberately, so treating every non-nil return as a failure means logging an error on every clean exit:
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server failed: %v", err)
}errors.As asks "give me the typed one"
Use it to pull a concrete error type out of the chain so you can read its fields.
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println("operation:", pathErr.Op) // "open"
fmt.Println("path:", pathErr.Path) // "app.yaml"
}Three things about the signature catch people out.
The second argument must be a pointer to the type you want. errors.As(err, pathErr) does not compile, and errors.As(err, &err) is not what you meant.
Declare the variable first. It gets assigned only if a match is found.
It returns a bool, and the variable is only valid when that is true.
// Wrong
if errors.As(err, &os.PathError{}) { } // not a pointer to a variable
// Right
var pathErr *os.PathError
if errors.As(err, &pathErr) {
use(pathErr)
}A worked example
func classify(err error) string {
var (
syntaxErr *json.SyntaxError
typeErr *json.UnmarshalTypeError
netErr *net.OpError
apiErr *APIError
)
switch {
case errors.As(err, &syntaxErr):
return fmt.Sprintf("malformed JSON at byte %d", syntaxErr.Offset)
case errors.As(err, &typeErr):
return fmt.Sprintf("field %q expected %s, got %s",
typeErr.Field, typeErr.Type, typeErr.Value)
case errors.As(err, &netErr):
return fmt.Sprintf("network error during %s", netErr.Op)
case errors.As(err, &apiErr):
return fmt.Sprintf("api returned %d", apiErr.StatusCode)
case errors.Is(err, context.DeadlineExceeded):
return "request timed out"
default:
return "unexpected error"
}
}An expressionless switch mixing errors.As and errors.Is is the standard shape for this. It reads top to bottom, specific cases first.
Choosing between them
Is this a specific known error? → errors.Is(err, ErrSomething)
Is this a specific kind of error, and
I need its fields? → errors.As(err, &target)If the sentinel is a value, use Is. If you need to read something off the error, use As.
Extracting behaviour instead of a type
errors.As works with interfaces too, which lets you ask what an error can do rather than what it is:
type retryable interface {
Retryable() bool
}
func shouldRetry(err error) bool {
var r retryable
return errors.As(err, &r) && r.Retryable()
}Any error from any package that happens to have a Retryable() bool method now matches, with no import relationship between the two. This is the loosest and often the most useful form of error inspection.
Errors and context cancellation
Two cases that come up in every service with timeouts, and they are worth distinguishing:
if errors.Is(err, context.Canceled) {
// The caller went away. Not a server failure.
// Usually: log at debug level, do not alert.
return
}
if errors.Is(err, context.DeadlineExceeded) {
// We were too slow. This is a real problem.
slog.Warn("operation exceeded deadline", "error", err)
respond(w, http.StatusGatewayTimeout, "request timed out")
return
}Treating a client disconnect as a 500 pollutes your error rate metrics with things that were never your fault, and it is one of the most common causes of misleading dashboards in Go services.
Joined errors
errors.Join produces an error containing several, and both functions handle it:
err := errors.Join(ErrNotFound, ErrPermission)
errors.Is(err, ErrNotFound) // true
errors.Is(err, ErrPermission) // true, both are in thereerrors.As finds the first matching type in the tree, walking breadth first.
func validate(u User) error {
var errs []error
if u.Name == "" {
errs = append(errs, &ValidationError{Field: "name"})
}
if u.Email == "" {
errs = append(errs, &ValidationError{Field: "email"})
}
return errors.Join(errs...)
}
// Finds the first one, but not all of them
var vErr *ValidationError
errors.As(err, &vErr)When you need every error rather than the first, unwrap the slice yourself:
func allValidationErrors(err error) []*ValidationError {
var out []*ValidationError
var multi interface{ Unwrap() []error }
if errors.As(err, &multi) {
for _, e := range multi.Unwrap() {
var v *ValidationError
if errors.As(e, &v) {
out = append(out, v)
}
}
return out
}
var v *ValidationError
if errors.As(err, &v) {
out = append(out, v)
}
return out
}The error handling boundary
Most services do their error inspection in exactly one place: where an internal error becomes a response. Everything below that layer wraps and returns.
func (h *Handler) mapError(w http.ResponseWriter, r *http.Request, err error) {
var (
status = http.StatusInternalServerError
code = "internal_error"
msg = "something went wrong"
)
var validation *ValidationError
var conflict *ConflictError
switch {
case errors.Is(err, ErrNotFound):
status, code, msg = http.StatusNotFound, "not_found", "resource not found"
case errors.Is(err, ErrUnauthorized):
status, code, msg = http.StatusUnauthorized, "unauthorized", "authentication required"
case errors.As(err, &validation):
status, code = http.StatusUnprocessableEntity, "validation_failed"
msg = validation.Error()
case errors.As(err, &conflict):
status, code = http.StatusConflict, "conflict"
msg = conflict.Error()
case errors.Is(err, context.Canceled):
return // client hung up, nothing to send
case errors.Is(err, context.DeadlineExceeded):
status, code, msg = http.StatusGatewayTimeout, "timeout", "request timed out"
}
// Log the full chain internally, send the sanitised version outward.
if status >= 500 {
slog.ErrorContext(r.Context(), "request failed",
"error", err,
"path", r.URL.Path,
"method", r.Method,
)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"code": code, "message": msg})
}Notice the split between what gets logged and what gets sent. The log has the whole wrapped chain including file paths and query text. The client gets a code and a short message. Leaking internal errors to clients is both a security problem and a support problem.
Concentrating error inspection at one boundary is what makes wrapping worth the effort. Every layer below adds context and returns. One layer at the top asks the questions and decides. Spread the inspection across every layer and you get inconsistent responses and duplicated logic.
Next, let's cover the two features this section has deliberately avoided so far, and the narrow circumstances where they belong.
How is this guide?
Last updated on
