Skip to content

ErrorInfo

Go · package github.com/muthuishere/toolnexus/golang · SPEC §8 · golang/client.go

type ErrorInfo struct {
Err error // transport error (nil on a non-ok response)
Status int // HTTP status (0 on a transport error)
Attempt int // zero-based attempt index
Retryable bool // true if Status/Err is in the default retryable set
// (429, 500, 502, 503, 504, 529, or any transport error)
}
type Tier string
const (
TierRetry Tier = "retry"
TierFail Tier = "fail"
)
// ClientOptions.OnError func(ErrorInfo) Tier
// ClientOptions.Retries, RetryBaseMs, TimeoutMs int
// ClientOptions.RetryableStatuses []int

Three independent resilience knobs on ClientOptions: retry classification (OnError, given an ErrorInfo per failed attempt, returns TierRetry or TierFail), a whole-run deadline (TimeoutMs), and ctx cancellation — both propagate to abort the in-flight HTTP request rather than let it run to completion unheeded.

The defaults (retry 429, 500, 502, 503, 504, 529 and transport errors up to Retries, exponential backoff honoring Retry-After, no deadline) are right for most callers — you get resilience for free by doing nothing. Note that the default set is an exhaustive enumeration, not “429 plus any 5xx”: a 501, a 507 or a Cloudflare 520 is terminal unless you say otherwise. Reach for RetryableStatuses when your provider emits a transient status outside that set; reach for OnError when a provider’s failure modes don’t fit the default classifier (e.g. treat a specific 400 as retryable, or stop retrying 429 early because you’re rate-limit-sensitive); reach for TimeoutMs to bound worst-case latency on a run; reach for ctx cancellation for the ordinary Go pattern of “the caller gave up, stop everything downstream.”

Widening the default set with RetryableStatuses

Section titled “Widening the default set with RetryableStatuses”

A Cloudflare-fronted origin answers 520–527 when the edge cannot get a clean response from the origin. Those are transient, and not in the default set — list them and they classify as retryable:

client, err := toolnexus.CreateClient(toolnexus.ClientOptions{
Model: model,
APIKey: apiKey,
RetryableStatuses: []int{520, 521, 522, 523, 524, 525, 526, 527},
})

RetryableStatuses is additive to the defaults, never a replacement — it can only make more statuses retryable, never fewer, so setting it cannot accidentally drop 429 and lose Retry-After handling along with it. It sets the default classification only: OnError still runs on every failed attempt and has the final say, so an OnError returning TierFail overrides a status you listed yourself. Retry-After handling is unaffected.

Each example points CreateClient at a local httptest.Server that returns 429/503 or stalls — no real network call, no real API key. RetryBaseMs/TimeoutMs are set small so the examples run fast.

1. The smallest useful call — default retry on a transient 503

Section titled “1. The smallest useful call — default retry on a transient 503”

No OnError set: the default classifier retries 429/500/502/503/504/529/transport errors up to Retries (2 by default), with exponential backoff.

package main
import (
"context"
"fmt"
"log"
"net/http"
"net/http/httptest"
"sync/atomic"
toolnexus "github.com/muthuishere/toolnexus/golang"
)
func main() {
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if atomic.AddInt32(&calls, 1) <= 2 {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"error":"try later"}`))
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"hello"}}]}`))
}))
defer srv.Close()
tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{})
if err != nil {
log.Fatal(err)
}
defer tk.Close()
client := toolnexus.CreateClient(toolnexus.ClientOptions{
BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "test-key", RetryBaseMs: 1,
})
res, err := client.Run(context.Background(), "hi", tk)
if err != nil {
log.Fatal(err)
}
if res.Text != "hello" || atomic.LoadInt32(&calls) != 3 {
log.Fatalf("text=%q calls=%d, want hello/3", res.Text, calls)
}
fmt.Println("ok: succeeded after", calls, "attempts")
}

2. The realistic case — a custom OnError classifier

Section titled “2. The realistic case — a custom OnError classifier”

Return TierFail to stop retrying a status the default set would otherwise retry (rate-limit sensitive callers often want this for 429), or TierRetry to retry something the default set treats as terminal.

package main
import (
"context"
"fmt"
"log"
"net/http"
"net/http/httptest"
"sync/atomic"
toolnexus "github.com/muthuishere/toolnexus/golang"
)
func main() {
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"error":"slow down"}`))
}))
defer srv.Close()
tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{})
if err != nil {
log.Fatal(err)
}
defer tk.Close()
var seen toolnexus.ErrorInfo
client := toolnexus.CreateClient(toolnexus.ClientOptions{
BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "test-key", RetryBaseMs: 1,
OnError: func(info toolnexus.ErrorInfo) toolnexus.Tier {
seen = info
return toolnexus.TierFail // fail fast on 429 instead of retrying
},
})
_, err = client.Run(context.Background(), "hi", tk)
if err == nil {
log.Fatal("expected an error when OnError returns TierFail")
}
if atomic.LoadInt32(&calls) != 1 {
log.Fatalf("calls = %d, want 1 (TierFail ⇒ no retry)", calls)
}
if seen.Status != 429 || !seen.Retryable || seen.Attempt != 0 || seen.Err != nil {
log.Fatalf("ErrorInfo = %+v, want Status:429 Retryable:true Attempt:0 Err:nil", seen)
}
fmt.Println("ok: failed fast on attempt", seen.Attempt, "status", seen.Status)
}

3. The full surface — timeout, cancellation, and retry exhaustion together

Section titled “3. The full surface — timeout, cancellation, and retry exhaustion together”

A run-level TimeoutMs aborts an in-flight request that’s taking too long; ctx cancellation does the same for an ordinary “caller gave up,” and neither retries afterward — only the retry classifier governs retries, never a deadline or a cancellation.

package main
import (
"context"
"fmt"
"log"
"net/http"
"net/http/httptest"
"sync/atomic"
"time"
toolnexus "github.com/muthuishere/toolnexus/golang"
)
func main() {
tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{})
if err != nil {
log.Fatal(err)
}
defer tk.Close()
// 1. TimeoutMs aborts a run that would otherwise hang.
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-time.After(2 * time.Second):
}
}))
defer slow.Close()
timeoutClient := toolnexus.CreateClient(toolnexus.ClientOptions{
BaseURL: slow.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "test-key", TimeoutMs: 80,
})
start := time.Now()
if _, err := timeoutClient.Run(context.Background(), "hi", tk); err == nil {
log.Fatal("expected a timeout error")
}
if time.Since(start) > time.Second {
log.Fatal("Run should have aborted well under 1s")
}
// 2. ctx cancellation aborts in-flight and is never retried.
var calls int32
hang := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
select {
case <-r.Context().Done():
case <-time.After(2 * time.Second):
}
}))
defer hang.Close()
cancelClient := toolnexus.CreateClient(toolnexus.ClientOptions{
BaseURL: hang.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "test-key", RetryBaseMs: 1,
})
ctx, cancel := context.WithCancel(context.Background())
go func() { time.Sleep(50 * time.Millisecond); cancel() }()
if _, err := cancelClient.Run(ctx, "hi", tk); err == nil {
log.Fatal("expected a cancellation error")
}
if atomic.LoadInt32(&calls) != 1 {
log.Fatalf("calls = %d, want 1 (cancellation must not retry)", calls)
}
// 3. Retries is a hard budget — exhausting it surfaces the failure, never loops forever.
var failCalls int32
failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&failCalls, 1)
w.WriteHeader(http.StatusBadGateway)
}))
defer failing.Close()
budgetClient := toolnexus.CreateClient(toolnexus.ClientOptions{
BaseURL: failing.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "test-key", Retries: 1, RetryBaseMs: 1,
})
if _, err := budgetClient.Run(context.Background(), "hi", tk); err == nil {
log.Fatal("expected an error after the retry budget is exhausted")
}
if atomic.LoadInt32(&failCalls) != 2 { // 1 try + 1 retry
log.Fatalf("calls = %d, want 2 (1 try + Retries:1)", failCalls)
}
fmt.Println("ok: timeout, cancellation, and retry exhaustion all abort cleanly")
}
Field Type Default What it does
Retries int 2 Retries on transient LLM errors (429, 500, 502, 503, 504, 529, or a transport error), bounded — never unbounded even with a custom OnError.
RetryBaseMs int 500 Base backoff in ms; exponential + jitter, honoring a Retry-After header when present.
TimeoutMs int 0 (no deadline) When > 0, the whole run (and its in-flight request) is aborted once exceeded.
RetryableStatuses []int nil Extra HTTP statuses to classify as retryable, added to the default 429/500/502/503/504/529. Widens only, never shrinks; OnError still has the final say.
OnError func(ErrorInfo) Tier the default classifier Classify each failed attempt; nil ⇒ retryable status/network ⇒ TierRetry, else TierFail (byte-identical to no classifier at all).
HTTPClient *http.Client http.DefaultClient Overrides the client used for LLM requests (retries included); scope is the LLM path only.

Classifier carries the same knob under the same name: ClassifierOptions.RetryBaseMs (default 500, base * 2^attempt, no jitter, Retry-After still wins). See CreateClassifier.

  • CreateClient — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.
  • Client.Run — Send a prompt, let the loop call tools until the model stops, get a RunResult.
  • Hooks — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite.
  • MetricEvent — A failed run still fires a terminal "run" metric event with Error set.