skip to content

Retryable and Terminal Failures

Whether a caller may retry has to live in the error value itself, as a sentinel, a method or a typed cause. Interviewers ask because retrying a context cancellation is a real bug.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

How should a Go package carry retryability inside the error value it returns?

level: middleimportance: must knowfreq 62%

answer

  1. the decision is made far above the failure
  2. whatever you use must survive wrapping
  3. a second bool return gets dropped
  4. sentinel matched with errors.Is
  5. a method found through an interface target

basics

~20 s

Put 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 s

Retryability 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 lines
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 }

go deeper

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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
open as a page

Why is an error that matches context.Canceled never worth retrying in Go?

level: juniorimportance: should knowfreq 50%

basics

~20 s

context.Canceled means someone deliberately called the work off, so the cause never clears. Any attempt reusing that context fails immediately, and a fresh one produces a result nobody is waiting for. Stop and return the error.

open as a page

A Go queue worker retries every non-nil error and is overloading a failing downstream. How do you fix its classification?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Replace the retry-everything default with one classify function mapping an error to retryable, terminal or abandoned via errors.Is and errors.As, cap attempts with the envelope's delivery count, and count failures by error class and attempt number.

open as a page

In Go, should a library's error value declare its own retryability, or should the caller classify it?

level: principalimportance: nice to knowfreq 28%

basics

~20 s

Export facts, not verdicts. A library knows what happened — whether the request was sent, what the peer said — but not the caller's cost of a duplicate effect. Declare retryability only where the library alone can know it.

open as a page