Code review on ClaudeGate. The HTTP handler that receives execution jobs was returning a 500 when the job queue was full. A colleague caught it in the logs: "this isn't an internal error, it's saturation — it should be a 503."
He was right. The problem was a single innocent line: all errors from
Enqueue treated the same way, without distinguishing their nature.
That's the moment you have to ask the real question: how do you structure errors
so the caller can respond correctly?
The concrete bug — 500 instead of 503
The original code in the HTTP handler looked like this:
// ❌ Before — any enqueue error returns 500
if err := h.queue.Enqueue(j.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to enqueue job")
return
}
"Queue full" and "internal error" are not the same thing. A server with a full queue is working correctly — it's temporarily saturated. A client receiving 500 assumes the server is broken and probably won't retry. A client receiving 503 Service Unavailable knows it can retry in a few seconds.
To distinguish between the two cases, the error needs to be identifiable. That's where sentinel errors come in.
The fix — sentinel error + errors.Is
A sentinel error is an error value declared at the package level that the caller can compare directly. That's exactly what we need here:
// In queue.go
var ErrQueueFull = errors.New("queue full")
func (q *Queue) Enqueue(jobID string) error {
select {
case q.jobs <- jobID:
return nil
default:
return fmt.Errorf("%w: job %s", ErrQueueFull, jobID)
}
}
// In handler.go
if err := h.queue.Enqueue(j.ID); err != nil {
if errors.Is(err, queue.ErrQueueFull) {
writeError(w, http.StatusServiceUnavailable, "server busy, retry later")
} else {
writeError(w, http.StatusInternalServerError, "failed to enqueue job")
}
return
}
The %w in fmt.Errorf matters: it wraps the original
error inside the enriched message ("queue full: job abc-123"), and
errors.Is knows how to traverse that wrapping to find
ErrQueueFull. Without %w, you'd lose the ability to
compare.
The 3 Go error patterns
This ClaudeGate case illustrates why Go has several ways to handle errors. Here are the three patterns, when to use each, and why.
Pattern 1: fmt.Errorf — the general case
When the caller doesn't distinguish between failure causes — it just propagates
the error or logs it and moves on — fmt.Errorf is more than enough:
// ✅ The caller doesn't act differently based on error type
func processItem(ctx context.Context, item Item) error {
result, err := fetchData(ctx, item.ID)
if err != nil {
return fmt.Errorf("failed to fetch data for item %s: %w", item.ID, err)
}
if err := store.Save(ctx, result); err != nil {
return fmt.Errorf("failed to save result for item %s: %w", item.ID, err)
}
return nil
}
The %w is still useful here: it lets upper layers unwrap the error
if they need to, without having to anticipate that upfront.
Pattern 2: Sentinel error — the caller distinguishes multiple cases
When the caller needs to make different decisions based on the reason for failure, it needs a comparable value. That's the role of sentinel errors:
// Declared at package level
var (
ErrQueueFull = errors.New("queue full")
ErrJobNotFound = errors.New("job not found")
ErrJobCanceled = errors.New("job canceled")
)
// Returned with enriched context
func (q *Queue) GetJob(id string) (*Job, error) {
job, ok := q.jobs[id]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrJobNotFound, id)
}
return job, nil
}
// Caller that distinguishes cases
job, err := q.GetJob(jobID)
if err != nil {
switch {
case errors.Is(err, ErrJobNotFound):
http.NotFound(w, r)
case errors.Is(err, ErrJobCanceled):
writeError(w, http.StatusGone, "job was canceled")
default:
writeError(w, http.StatusInternalServerError, "unexpected error")
}
return
}
The standard library is full of sentinels: io.EOF,
sql.ErrNoRows, context.Canceled,
context.DeadlineExceeded. These are well-known values that any
caller can compare directly.
Pattern 3: Error struct — the error carries data
Sometimes the caller needs more than a boolean signal — it needs structured data
contained in the error itself. An error struct implementing the error
interface handles that:
// Struct definition
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error on field %s: %s", e.Field, e.Message)
}
// Returning it
func validatePayload(p Payload) error {
if p.Name == "" {
return &ValidationError{Field: "name", Message: "required"}
}
return nil
}
// Caller extracting the data
if err := validatePayload(p); err != nil {
var ve *ValidationError
if errors.As(err, &ve) {
respondWithFieldError(w, ve.Field, ve.Message)
return
}
writeError(w, http.StatusInternalServerError, "validation failed")
return
}
It's errors.As and not errors.Is for structs: you
extract the concrete type rather than comparing a value. The standard library does
the same with *os.PathError (carries Op + Path + Err) or
*url.Error (carries Method + URL + Err).
errors.Is vs errors.As — the difference
Both traverse the wrapping chain created by %w, but they do
different things:
// errors.Is — compares values (for sentinel errors)
// Returns true if any error in the chain == ErrQueueFull
errors.Is(err, ErrQueueFull)
// errors.As — extracts a type (for error structs)
// Walks the chain, assigns the first *ValidationError found into &ve
var ve *ValidationError
errors.As(err, &ve)
The classic trap with error structs:
// ❌ Direct type assertion — doesn't traverse %w wrapping
if _, ok := err.(*ValidationError); ok {
// Misses if the error was wrapped with fmt.Errorf("...: %w", ve)
}
// ✅ errors.As — traverses correctly
var ve *ValidationError
if errors.As(err, &ve) {
// Works even if wrapped
}
The decision table
| Situation | Pattern |
|---|---|
| The caller propagates or logs, no case distinction needed | fmt.Errorf("context: %w", err) |
| The caller distinguishes multiple failure causes | var ErrXxx = errors.New(...) + errors.Is |
| The error must carry structured data | Struct implementing error + errors.As |
What not to do
Three anti-patterns that keep showing up in code reviews:
// ❌ Comparing error strings — fragile and unmaintainable
if err.Error() == "queue full" {
// Breaks as soon as you change the error message
}
// ❌ Direct type assertion — misses wrapped errors
if _, ok := err.(*ValidationError); ok {
// Doesn't work if the error was wrapped
}
// ❌ Sentinel error when data matters
var ErrNotFound = errors.New("not found")
// Which ID? Which resource type? The caller has no idea.
// Better: a struct that carries that information
The last point is subtle. sql.ErrNoRows works as a sentinel because
the context (which query, which ID) is already known to the caller. But if the
error itself needs to carry that context, a struct is more appropriate.
Standard library examples worth remembering
The Go standard library is a good guide to what works in practice over time:
io.EOF— sentinel, no additional data, the caller just knows reading is donesql.ErrNoRows— sentinel, the caller distinguishes "no result" from "connection error"context.Canceled/context.DeadlineExceeded— two distinct sentinels, the caller can react differently to each*os.PathError— struct, carriesOp+Path+Err: essential for displaying a useful error message*url.Error— struct, carriesMethod+URL+Errfor debugging HTTP calls
Conclusion
The rule is simple: pick the simplest pattern that meets the caller's actual
needs. If the caller doesn't need to distinguish cases,
fmt.Errorf is enough. If the caller needs to make different
decisions, a sentinel error is the right tool. If the error needs to carry
structured data, an error struct is the answer.
In the ClaudeGate case, the fix was small — a sentinel error and two lines in the handler. But the difference for API clients was significant: a 503 with a "retry later" message gives them actionable information. A generic 500 tells them nothing useful.
Go's error system is deliberately minimal. It's on us to choose the right granularity based on what the caller actually needs to know — not based on what looks cleaner to write.