Errors in a Go agent: wrapping, sentinel families, errors.AsType, and what the model reads
Errors for two readers: the program, which decides what to do, and the model, which fixes its next call.
Most Go programs have one reader for their errors: the code that called them, and in the end the person who runs the program. An agent has two. A model call that failed, a budget that ran out, or a cancelled run goes to the program, which decides whether to retry, stop, or report. A tool call with bad arguments, or one that names a tool that doesn’t exist, goes to the model, as the tool’s result, and the model fixes its next call. Getting each error to the right reader, in a form that reader can act on, is most of the job.
The tools below are standard Go, several of them recent: errors.Join and wrapping more than one error with fmt.Errorf came in Go 1.20, and errors.AsType in Go 1.26.
All the code is from the companion repository, where it is tested.
Wrap with what you were doing
When a function returns an error from something it called, it adds what it was doing, with %w so the original stays inspectable:
// post sends a request to the chat completions endpoint and returns the
// response, whatever its status.
func (c *Client) post(ctx context.Context,
wire chatRequest) (*http.Response, error) {
body, err := json.Marshal(wire, jsontext.AllowInvalidUTF8(true))
if err != nil {
return nil, fmt.Errorf("encode request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL()+"/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+c.APIKey)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient().Do(httpReq)
if err != nil {
return nil, fmt.Errorf("send request: %w",
callError(ctx, err))
}
return resp, nil
}
Each layer adds a few words, and the message reads as a path from the outside in. In a test whose fake transport never answers, the call ends with this error, every part of it added by a different layer:
send request: context deadline exceeded: Post "https://openrouter.ai/api/v1/chat/completions": blocked: openrouter: model call timed out
send request is this function, Post "…" is net/http, blocked is the fake transport, and the rest is the cancellation and its cause, joined as the context page describes.
The conventions keep that path readable: lowercase, no trailing punctuation, no “failed to” or “error:” prefixes, and the name of what was being done, not of the function.
Use %w when callers may need the cause, and %v to deliberately hide it, at a boundary where the inner error is an implementation detail callers shouldn’t depend on.
Handle each error once: either log it or return it.
Code that does both prints the same failure at every layer it passes through.
Errors callers check: sentinels in families
Conditions a caller needs to recognize are exported sentinel errors, values compared with errors.Is.
An agent has enough of them that grouping helps, so a caller can ask about a family without listing its members:
// The errors Run returns are grouped into families, so a caller can ask
// "was the budget the problem?" with errors.Is(err, ErrBudget) and not
// care which limit it was.
var (
// ErrBudget: the run used up a limit the caller set.
ErrBudget = errors.New("agent: budget exhausted")
ErrMaxTurns = fmt.Errorf("%w: max turns reached", ErrBudget)
ErrBudgetExceeded = fmt.Errorf("%w: cost budget reached", ErrBudget)
// ErrBadReply: the model's reply can't be used as it is.
ErrBadReply = errors.New("agent: bad reply")
ErrBadToolCall = fmt.Errorf("%w: bad tool call", ErrBadReply)
ErrOutputTruncated = fmt.Errorf("%w: cut off at the length limit",
ErrBadReply)
ErrOutputFiltered = fmt.Errorf("%w: blocked by a content filter",
ErrBadReply)
ErrProviderFailed = fmt.Errorf("%w: provider failed partway",
ErrBadReply)
ErrIncompleteResponse = fmt.Errorf("%w: the stream ended early",
ErrBadReply) // chapter 6
ErrNoFinalAnswer = fmt.Errorf("%w: answered without the final tool",
ErrBadReply) // chapter 7
ErrRefused = fmt.Errorf("%w: the model refused", ErrBadReply) // chapter 11
)
Each member wraps its family with fmt.Errorf("%w: ..."), so errors.Is(err, ErrBudget) is true for both the turn limit and the cost budget, and errors.Is(err, ErrMaxTurns) still tells them apart.
A caller that just wants to know “did a limit I set stop this?” checks the family; one that reports which limit checks the member.
Errors that carry data: types and behavior
Some failures need more than a name. An API error has a status, the API’s own message, and sometimes a delay the server asked for. Those are a type, with methods that answer the questions callers ask:
// An APIError is a request the API refused, or a provider failure it
// reported. Callers inspect it with errors.AsType to decide what to do.
type APIError struct {
Status int // HTTP status, or the code in the reply
Message string // the API's own explanation
RetryAfter time.Duration // the wait the API asked for, or 0
}
func (e *APIError) Error() string {
status := strconv.Itoa(e.Status)
if text := http.StatusText(e.Status); text != "" {
status += " " + text
}
return fmt.Sprintf("openrouter: %s: %s", status, e.Message)
}
// Retryable reports whether the same request might succeed if sent
// again later: a timeout (408), too many requests (429), or trouble on
// the provider's side (5xx). Chapter 12 retries these.
func (e *APIError) Retryable() bool {
return e.Status == http.StatusRequestTimeout ||
e.Status == http.StatusTooManyRequests || e.Status >= 500
}
errors.AsType finds the first error in a chain with a given type and returns it, typed: e, ok := errors.AsType[*APIError](err).
It is the generic form of errors.As, added in Go 1.26; it needs no variable declared in advance, and it can’t be handed a pointer of the wrong shape, a common mistake with errors.As.
The retry code goes one step further and asks about behavior, not type. It defines small interfaces for what an error can report about itself:
// Errors that report on themselves: retryable, such as chapter 5's
// *APIError and this chapter's *RetryError; delayed, an error that
// carries the server's requested wait; and timeout, such as a call's
// own deadline or chapter 6's idle stream, and net.Error.
type (
retryable interface {
error
Retryable() bool
}
delayed interface {
error
RetryDelay() time.Duration
}
timeout interface {
error
Timeout() bool
}
)
and checks for them with the same function, given an interface instead of a concrete type:
// Classify decides whether err, from a call made under ctx, the
// caller's context rather than the attempt's, is worth retrying.
func Classify(ctx context.Context, err error) Decision {
switch {
case err == nil:
return Decision{}
case ctx.Err() != nil:
return Decision{Why: "cancelled"}
case errors.Is(err, ErrToolFatal):
return Decision{Why: "fatal"}
}
if r, ok := errors.AsType[retryable](err); ok {
d := Decision{Retry: r.Retryable(), Why: "status"}
if w, ok := errors.AsType[delayed](err); ok {
d.After = w.RetryDelay()
}
return d
}
if timedOut(err) {
return Decision{Retry: true, Why: "timed out"}
}
if transient(err) {
return Decision{Retry: true, Why: "network"}
}
return Decision{Why: "permanent"}
}
Any error that has a Retryable method, from either provider’s client or from the retry code itself, is classified the same way, and the retry code imports none of those packages.
The order of the cases matters: a cancelled run is never retried, whatever the error says, and an error that reports itself is believed before the guesswork of timeouts and network errors.
One type uses that behavior to prevent a mistake.
A call retried until the policy gave up returns a *RetryError, which says it is not retryable, so a retry loop further out doesn’t start the whole series again:
// A RetryError is the last error of a call that was retried until the
// policy gave up. It reports itself as not retryable, so a layer
// outside, another wrapper or the caller's own loop, doesn't retry the
// whole series again and multiply the attempts.
type RetryError struct {
Attempts int
Err error
}
func (e *RetryError) Error() string {
return fmt.Sprintf("gave up after %d attempts: %v",
e.Attempts, e.Err)
}
// Unwrap returns the last attempt's error.
func (e *RetryError) Unwrap() error { return e.Err }
// Retryable is always false: the retries already happened.
func (e *RetryError) Retryable() bool { return false }
Errors the model reads
A tool’s error doesn’t stop the run. It becomes the tool’s result, marked as an error, and the model reads it on its next turn. The text has to be written for that reader: what was wrong, and how to make the call correctly. The agent keeps the text for the model separate from the kind of error the program checks:
// ErrTool and its members name problems with one tool call. Run never
// returns them: they become tool messages, and the model tries again.
var (
ErrTool = errors.New("agent: tool call failed")
ErrUnknownTool = fmt.Errorf("%w: unknown tool", ErrTool)
ErrToolArgs = fmt.Errorf("%w: bad arguments", ErrTool)
ErrToolPanic = fmt.Errorf("%w: the tool panicked", ErrTool)
)
// A toolError is a problem with one tool call. Its message is what the
// model reads; its kind is for errors.Is. A tool that wraps a kind with
// fmt.Errorf instead would send the model the kind's text as well.
type toolError struct {
kind error
msg string
}
func (e *toolError) Error() string { return e.msg }
func (e *toolError) Unwrap() error { return e.kind }
Error() returns only the message, so the model reads something like no tool named "read"; the tools are edit_file, list_files, read_file, run_tests, not a chain of package prefixes, while Unwrap still lets the program count unknown-tool errors with errors.Is.
Here is that error being made, with the list of real tools the model needs to fix its call:
// lookupAndRun finds the tool a call names and runs it.
func lookupAndRun(ctx context.Context, byName map[string]Tool,
call ToolCall) (string, error) {
t, ok := byName[call.Name]
if !ok {
names := slices.Sorted(maps.Keys(byName))
return "", &toolError{ErrUnknownTool, fmt.Sprintf(
"no tool named %q; the tools are %s",
call.Name, strings.Join(names, ", "))}
}
return runSafely(ctx, t, call)
}
// runSafely runs a tool and turns a panic inside it into an error the
// model reads, so one broken tool can't crash the whole agent. The
// panic is a bug in the tool, so the message says to try another way.
func runSafely(ctx context.Context, t Tool, call ToolCall) (
out string, err error) {
defer func() {
if p := recover(); p != nil {
out, err = "", &toolError{ErrToolPanic, fmt.Sprintf(
"%s failed with an internal error (%v); "+
"try another way, or report it", call.Name, p)}
}
}()
return t.Run(ctx, call.Arguments)
}
Arguments that don’t decode get the same treatment, with one more detail: encoding/json/v2 deliberately varies the wording of its error messages between runs, so the agent writes its own stable message from the error’s fields, through errors.AsType:
// decodeArgs decodes a tool call's arguments. When they don't decode,
// the error says what was wrong and shows the shape the tool expects,
// so the model can fix its next call.
func decodeArgs[T any](args jsontext.Value, example string) (T, error) {
var v T
err := json.Unmarshal(args, &v,
json.RejectUnknownMembers(true))
if err != nil {
return v, &toolError{ErrToolArgs, fmt.Sprintf(
"bad arguments: %s; send JSON like %s", argError(err), example)}
}
return v, nil
}
// argError says why arguments didn't decode, in the same words every
// time. json/v2 deliberately varies the wording of its errors between
// runs, and this text goes to the model and into saved runs.
func argError(err error) string {
if _, ok := errors.AsType[*jsontext.SyntacticError](err); ok {
return err.Error() // syntax errors read the same every time
}
se, ok := errors.AsType[*json.SemanticError](err)
if !ok || se.GoType == nil {
return "the JSON doesn't fit the arguments"
}
where := "the arguments"
if p := strings.TrimPrefix(string(se.JSONPointer), "/"); p != "" {
where = strconv.Quote(p)
}
if errors.Is(se.Err, json.ErrUnknownName) {
return "unknown field " + where
}
return fmt.Sprintf("%s: got %s, want %s", where,
kindOf(se.JSONKind), goKind(se.GoType.Kind()))
}
Stable wording matters twice here: a model reads it, and recorded runs used as test fixtures would otherwise change from one run to the next.
Errors that stop the run
Some tool failures can’t be fixed by a better call: the workspace the tool works in is gone, or the server behind it died. Sending those to the model just spends turns. They are marked with a sentinel of their own:
// ErrToolFatal means a tool can't run at all, such as when its
// workspace is gone: no retry or change of arguments can help, so the
// loop stops the run instead of sending the model an error to work
// around (chapter 9). A tool wraps it: fmt.Errorf("%w: ...", ErrToolFatal).
var ErrToolFatal = errors.New("agent: a tool can't run")
The code that runs a tool sorts every result into one of three outcomes: an answer, an error for the model, or an error that also stops the run:
// runTool runs one tool call and returns the tool message that answers
// it. Every problem becomes an error message the model can read; a tool
// that can't run at all is also returned as an error, which stops the
// run.
func runTool(ctx context.Context, byName map[string]Tool,
call ToolCall) (Message, error) {
msg := Message{Role: "tool", ToolCallID: call.ID}
ctx = context.WithValue(ctx, callKey{}, call) // for wrappers (chapter 12)
out, err := lookupAndRun(ctx, byName, call)
switch {
case errors.Is(err, ErrToolFatal):
msg.IsError, msg.Content = true, "not run: "+err.Error()
return msg, fmt.Errorf("%s: %w", call.Name, err)
case errors.Is(err, ErrOutcomeUnknown): // chapter 12
msg.IsError, msg.Content = true, "outcome unknown: "+err.Error()
return msg, fmt.Errorf("%s: %w", call.Name, err)
case err != nil: // the model reads it and tries again
msg.IsError, msg.Content = true, err.Error()
default:
msg.Content = out
}
return msg, nil
}
Even a fatal error produces a tool message, because every tool call in a conversation needs an answer; the run then stops with the error, wrapped with the tool’s name.
Panics at the tool boundary
A panic is for a programmer’s mistake, not for a failure the program expects. But a tool is code too, and a bug in one tool shouldn’t crash an agent partway through a long run. The tool boundary is the one place the agent recovers, and it turns the panic into an error the model reads:
// runSafely runs a tool and turns a panic inside it into an error the
// model reads, so one broken tool can't crash the whole agent. The
// panic is a bug in the tool, so the message says to try another way.
func runSafely(ctx context.Context, t Tool, call ToolCall) (
out string, err error) {
defer func() {
if p := recover(); p != nil {
out, err = "", &toolError{ErrToolPanic, fmt.Sprintf(
"%s failed with an internal error (%v); "+
"try another way, or report it", call.Name, p)}
}
}()
return t.Run(ctx, call.Arguments)
}
Recovering anywhere else would hide bugs; recovering here keeps one broken tool from losing the run, and the error still names the tool and the panic, for the person reading the log.
Several failures at once: errors.Join
Sometimes two things fail and both matter: a write fails, and putting the files back fails too.
errors.Join returns one error that holds both, and errors.Is and errors.AsType search every branch:
before := map[string]file{}
for _, rel := range slices.Sorted(maps.Keys(merged)) {
var err error
if before[rel], err = read(dir, rel); err != nil {
return err
}
if err := write(dir, rel, merged[rel]); err != nil {
return errors.Join(err, restore(dir, before))
}
}
if err := builds(ctx, dir); err != nil {
return errors.Join(err, restore(dir, before))
}
return nil
}
Parallel tool calls are the other common case: when several calls in one reply can’t run at all, the runner joins their errors, so the run reports every one instead of whichever finished first.
A joined error’s Unwrap returns a slice, Unwrap() []error, so code that walks a chain by hand has to follow both shapes.
The retry code does, because it looks for any error in the chain with a Timeout method that returns true, which errors.AsType can’t express, since it stops at the first error with the method whatever it returns:
// timedOut reports whether any error in err's chain is a timeout. It
// looks at every one, not the first that has a Timeout method, because
// a *url.Error answers for the error directly inside it only, and the
// client's own timeout is usually wrapped deeper.
func timedOut(err error) bool {
if errors.Is(err, ErrAttemptTimeout) {
return true
}
for err != nil {
//nolint:errorlint // this walks the chain one error at a time
if t, ok := err.(timeout); ok && t.Timeout() {
return true
}
switch u := err.(type) { //nolint:errorlint // the same walk
case interface{ Unwrap() []error }:
return slices.ContainsFunc(u.Unwrap(), timedOut)
case interface{ Unwrap() error }:
err = u.Unwrap()
default:
return false
}
}
return false
}
Mistakes to avoid
- Comparing error strings. Use
errors.Isanderrors.AsType; message text changes between versions and, as withencoding/json/v2, sometimes between runs. The retry code makes one exception:net/httpreports an HTTP/2 stream reset and a closed idle connection with errors it doesn’t export, so the only way to recognize them is their text. - A nil pointer returned as an error. A function that returns
errorand ends withvar e *APIError; return ereturns a non-nil error holding a nil pointer, anderr != nilis true. Returnnilitself. - Logging and returning. The same failure appears at every layer. Pick one.
%wat a boundary you don’t want to commit to. Wrapping makes the inner error part of your API: callers will check for it. Use%vwhere it is an implementation detail.- A Go error string sent to the model as is. “json: cannot unmarshal string into Go struct field” tells a model little. Say what was wrong and what a right call looks like.
- Recovering panics everywhere. Recover at the tool boundary, where a failure has somewhere useful to go, and let real bugs elsewhere crash in tests.
Try it
From the code directory of a clone of the companion repository:
$ go test ./ch12/agent -run 'TestClassify|TestToolPanic|TestToolFatal' -v
- In
TestClassify, add a case for an error that wraps a*RetryErrorin anotherfmt.Errorf, and check that it still isn’t retried. - Write a tool that panics on purpose, run it through the loop with a scripted model, and read the tool message the model would get.
- Change
toolError.Errorto returne.kind.Error() + ": " + e.msg, and look at what the model would read for an unknown tool.