How should a Go package carry retryability inside the error value it returns?
answer
- the decision is made far above the failure
- whatever you use must survive wrapping
- a second bool return gets dropped
- sentinel matched with errors.Is
- a method found through an interface target
basics
~20 sPut the classification in the error itself: an exported sentinel matched with errors.Is, or an error type with a method such as Retryable() bool found with errors.As. Both survive wrapping; a second bool return and message text do not.
solid answer
~50 sRetryability has to travel with the error, because the decision is usually made several layers above where the failure happened. Two shapes work. The first is an exported sentinel — `var ErrTemporary = errors.New("temporary failure")` — wrapped into the returned error with `fmt.Errorf("...: %w", ErrTemporary)` and matched by the caller with `errors.Is(err, ErrTemporary)`. The second is a behavioural method: an error type declaring `Retryable() bool`, which the caller finds without importing the concrete type by using an anonymous interface as the `errors.As` target. The behavioural form is what the standard library itself uses for timeouts, and it lets several unrelated packages agree on one classification. What does not work is a second `bool` return value, which the first caller that wraps and returns the error drops on the floor, or matching on `err.Error()` text, which no package promises to keep stable.
code
go · 8 linestype TransientError struct {
Op string
Err error
}
func (e *TransientError) Error() string { return e.Op + ": " + e.Err.Error() }
func (e *TransientError) Unwrap() error { return e.Err }
func (e *TransientError) Retryable() bool { return true }go deeper
Know that the classification belongs in the error value itself, and that callers read it back with errors.Is for a sentinel or errors.As for a typed error. Never decide from the error's text.
Be able to write both shapes correctly: the %w wrap and errors.Is for a sentinel, and an error type with a Retryable() bool found through an anonymous interface target. Explain why wrapping is what rules out a second return value.
Argue about what the error should carry beyond one bit — whether the request was actually sent, a peer-supplied wait hint — because that is what makes retrying a non-idempotent operation safe or unsafe.
Treat the exported sentinel or method as a compatibility promise other teams' retry loops depend on. Decide what your package guarantees and what it refuses to guarantee before the first consumer ships.
## The problem: the decision is far from the failure A failure happens deep in a call stack — a socket write, a query, a serialization step. The decision about whether to try again is made near the top, in a worker loop or a request handler. Everything in between only forwards the error, usually wrapping it for context. So whatever encodes "this may work next time" has to be **inside the error value**, and it has to survive being wrapped. That single requirement rules out the two approaches people reach for first. - **A second return value** — `func Do() (Result, error, bool)` or an `ErrRetryable bool` field on some result struct. The first intermediate caller that does `if err != nil { return fmt.Errorf("loading user: %w", err) }` has nowhere to put the bool, and it is gone. - **Message text** — `strings.Contains(err.Error(), "timeout")`. `Error()` output is prose for humans. No package promises its stability, wrapping prefixes it, and the same word appears in errors that mean something else. ## Shape one: an exported sentinel ```go var ErrTemporary = errors.New("temporary failure") func (c *Client) Fetch(ctx context.Context, id string) ([]byte, error) { b, err := c.do(ctx, id) if err != nil { return nil, fmt.Errorf("fetch %s: %w", id, ErrTemporary) } return b, nil } ``` The caller writes `if errors.Is(err, ErrTemporary)`. `errors.Is` walks the `Unwrap` chain, so any number of intermediate `%w` wraps are transparent. The sentinel is cheap and unambiguous, but it is a single bit: it says "retryable" and nothing about which operation failed, how long to wait, or whether the request actually reached the other side. Note the `%w` verb specifically. `%v` formats the error into the message and breaks the chain; `%w` keeps the wrapped error reachable by `errors.Is` and `errors.As`. Getting this wrong is the single most common reason a classification silently stops working after a refactor. ## Shape two: a behavioural method ```go type TransientError struct { Op string Err error } func (e *TransientError) Error() string { return e.Op + ": " + e.Err.Error() } func (e *TransientError) Unwrap() error { return e.Err } func (e *TransientError) Retryable() bool { return true } ``` The caller does not need to import the type at all: ```go var r interface{ Retryable() bool } if errors.As(err, &r) && r.Retryable() { // another attempt is permitted } ``` `errors.As` accepts a pointer to an interface type as its target, and assigns the first error in the chain that implements it. That is what makes this shape composable: three different packages can each define their own error type with a `Retryable() bool` method, and one classification function in the worker handles all of them without importing any of them. This is the standard library's own pattern — network errors expose `Timeout() bool` through the `net.Error` interface, and callers routinely match the behaviour rather than the concrete type. The library also carries a warning: an older `Temporary() bool` method on that interface was deprecated precisely because "temporary" was never given a definition anyone agreed on. If you export a `Retryable() bool`, document exactly what it promises — most usefully, whether the operation might have been applied on the other side. ## Carrying more than a bit The typed form's real advantage is that it can carry the facts the retry decision actually needs: ```go type SendError struct { Op string Sent bool // did the request leave the process? RetryAfter time.Duration // if the peer told us Err error } ``` `Sent` matters because a retry of a non-idempotent operation is only safe if you know the first attempt never happened. A sentinel cannot express that; a typed error can, and the caller extracts it with `errors.As(err, &se)` into a `*SendError`. ## Both shapes are API Once exported, either shape is a compatibility promise. Callers write `errors.Is(err, ErrTemporary)` or `Retryable()` checks against it, and if a later release stops setting it — even for a good reason — every consumer's retry loop silently changes behaviour with no compile error. Choose deliberately, document the meaning, and prefer exporting facts about what happened over exporting a verdict about what the caller should do. ## What an interviewer is listening for That you name wrapping as the reason the classification must live in the value; that you can write both the sentinel and the behavioural-interface check correctly, including `%w` and the pointer-to-interface target for `errors.As`; and that you notice this becomes part of the package's public contract.
- What breaks if an intermediate layer wraps the error with %v instead of %w?`%v` formats the wrapped error into the message text and stores no reference to it, so the `Unwrap` chain stops there. Every `errors.Is` and `errors.As` check above that layer fails, the retryable classification disappears, and the failure quietly falls into whatever your default bucket is. The error still reads fine to a human, which is why this survives review.
- When is a typed error worth the extra surface over a plain sentinel?When the retry decision needs facts, not just a verdict: whether the request actually left the process, how long the peer asked you to wait, which operation failed, or an identifier for the affected record. A sentinel carries one bit. A struct with fields lets the caller decide differently for an idempotent read and a non-idempotent write.
- Why does errors.As accept a pointer to an interface as its target?So callers can match on behaviour rather than identity. `errors.As` assigns the first error in the chain assignable to the target's element type, and an anonymous interface such as `interface{ Retryable() bool }` matches any error type declaring that method — including types from packages the caller never imports. That is what lets one classify function cover several dependencies.
- The standard library deprecated a Temporary() bool method on its network error interface. What is the lesson?That an undefined verdict is worse than no verdict. "Temporary" never had an agreed meaning, so different implementations set it differently and callers built retry loops on sand. If you export a retryability method, define exactly what it promises — most importantly whether the operation may already have taken effect — or export the facts and let the caller decide.
saying these in an interview costs you the question
- Returning a separate bool alongside the error
- Deciding retryability from err.Error() substrings
- Wrapping with %v and losing the chain
- Exporting Retryable() with no documented meaning
- Assuming errors.As only works with concrete struct types