Go Error Wrapping Strategies for Gin API Responses
Quick answer
Quick answer: wrap errors with context as they travel up the call stack, define a small set of typed or sentinel errors for conditions clients care about, and...
Quick answer: wrap errors with context as they travel up the call stack, define a small set of typed or sentinel errors for conditions clients care about, and translate them into HTTP responses in one place near the handler. Log each error once at that boundary, and never send raw database or internal messages to API clients.
How Should You Wrap Errors in Go?
Go lets you add context to an error with fmt.Errorf and the %w verb, which keeps the original error available for inspection. Each layer adds what it was doing, so a failure reads like a path through your code rather than a bare driver message. The wrapped chain remains compatible with errors.Is and errors.As, which is how callers recognize specific conditions.
The rule of thumb is to wrap when you cross a layer boundary and have something useful to add, such as the operation or the identifier involved. Do not wrap at every function call, which produces noisy, repetitive messages. Also avoid comparing error strings, because messages change and break silently.
Distinguish errors that clients should understand from errors that are only for operators. A missing record, a conflict, and invalid input are part of your API contract. A connection failure or a nil pointer is an internal problem that should be logged and reported as a generic server error.
Go also supports combining several errors. Since Go 1.20, fmt.Errorf accepts more than one %w verb and errors.Join merges multiple errors into one, which helps when cleanup of several resources can fail and you want every failure preserved. Check the release notes for the Go version you target.
var ErrNotFound = errors.New("not found")
func (r *Repo) ByID(ctx context.Context, id int64) (Order, error) {
o, err := r.query(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return Order{}, fmt.Errorf("order %d: %w", id, ErrNotFound)
}
if err != nil {
return Order{}, fmt.Errorf("load order %d: %w", id, err)
}
return o, nil
}
Where Should You Map Errors to HTTP Responses?
Centralize the translation. If every handler decides its own status codes, the same condition returns different responses across endpoints. A single helper or middleware that inspects the error and chooses the status and body keeps behavior consistent and makes changes cheap.
A typed application error is a convenient carrier. It holds a stable machine-readable code, a safe message for clients, and the underlying cause for logs. The mapping function checks for known sentinel errors with errors.Is, extracts typed errors with errors.As, and falls back to a generic 500 for everything else.
type APIError struct {
Status int
Code string
Message string
Err error
}
func (e *APIError) Error() string { return e.Code + ": " + e.Message }
func (e *APIError) Unwrap() error { return e.Err }
func respondError(c *gin.Context, err error) {
var apiErr *APIError
switch {
case errors.As(err, &apiErr):
c.JSON(apiErr.Status, gin.H{"code": apiErr.Code, "message": apiErr.Message})
case errors.Is(err, ErrNotFound):
c.JSON(http.StatusNotFound, gin.H{"code": "not_found", "message": "resource not found"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"code": "internal", "message": "internal error"})
}
}
What Mistakes Make Error Handling Painful?
The most common mistake is logging at every layer. The same failure then appears five times in your logs with different context, which makes incident analysis harder. Return errors upward and log once at the boundary, attaching request identifiers so entries can be correlated.
- Leaking internals: raw SQL errors, file paths, and stack details help attackers and confuse clients.
- Inconsistent response shapes: clients should parse one error format across the whole API.
- Losing the cause: using %v instead of %w breaks errors.Is and errors.As.
- Swallowing errors: ignoring a returned error hides failures that surface later as data problems.
- Using panics for normal control flow: reserve them for programmer errors and keep recovery middleware as a safety net.
Include a request ID in both the log entry and the error response body, so a client can report it and you can find the exact trace. Keep client messages stable, because other teams will build logic and tests around the error codes.
Frequently Asked Questions
When should I use errors.Is versus errors.As?
Use errors.Is to check for a specific sentinel value, and errors.As to extract an error of a particular type so you can read its fields. Both walk the wrapped chain.
Should I define sentinel errors or custom types?
Sentinels suit simple conditions such as not found. Custom types suit errors that carry data, like validation failures with field details. Many codebases use both.
Where should I log errors in a Gin application?
At the boundary where you translate them into responses, usually in a helper or middleware. Lower layers should return errors with context and leave logging to the boundary.
How do I avoid exposing internal errors?
Return a generic message and code for unknown errors, and keep the detailed cause only in logs. Map only recognized conditions to specific client messages.
Summary
Wrap errors with %w at layer boundaries, represent client-relevant conditions with sentinels or typed errors, and translate them centrally into consistent responses. Log once, include request IDs, and never expose internals. Check the Go standard library documentation for the error handling behavior in your Go version.