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 stringconst ( TierRetry Tier = "retry" TierFail Tier = "fail")
// ClientOptions.OnError func(ErrorInfo) Tier// ClientOptions.Retries, RetryBaseMs, TimeoutMs int// ClientOptions.RetryableStatuses []intThree 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.
When to use it
Section titled “When to use it”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.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”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")}ClientOptions resilience fields
Section titled “ClientOptions resilience fields”| 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. |
The classifier has the same backoff base
Section titled “The classifier has the same backoff base”Classifier carries the same knob under the same name: ClassifierOptions.RetryBaseMs (default 500, base * 2^attempt, no jitter, Retry-After still wins). See CreateClassifier.
See also
Section titled “See also”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 withErrorset.