Documentation
¶
Overview ¶
Package retry provides retry policy, backoff math, and per-policy exit-code classification — the data primitive that engine.RunWithRetry composes with a Dispatcher + Clock into the retry loop. No engine / state / clock deps here; the package is leaf-ward by design (Phase 3 will reuse it for try/catch integration without rewiring).
Index ¶
Constants ¶
const MaxHonoredRetryAfter = 5 * time.Minute
MaxHonoredRetryAfter caps how long a single server-supplied Retry-After hint can stretch one sleep. Honoring a hint verbatim is correct (the server knows its own reset window) up to a point — a buggy proxy or upstream that returns "retry-after: 86400" must not be allowed to hang the pipeline for a day. Real Anthropic 429/529 hints are seconds to low minutes; 5m is a generous ceiling that rides out a true overload while bounding the worst case.
Variables ¶
var Default = Policy{ Attempts: 8, Backoff: BackoffExp, Initial: time.Second, Max: 60 * time.Second, NonRetryableExitCodes: []int{78}, }
Default is the spec §6 default policy. The 78 sentinel is EX_CONFIG from sysexits.h — the conventional "configuration error, retry won't help" code. Treat as read-only — Merge deep-copies the NonRetryableExitCodes slice so callers can't accidentally mutate the shared default via index assignment (Revision #6 narrowed the var-vs-func footgun to the seam where it matters).
Attempts is 8 (not 3) so transient provider faults — Anthropic 429 rate_limit_error, 529 overloaded_error, 5xx, connection drops — ride out a normal rate-limit window or overload without failing the pipeline. With the exp curve capped at Max (1,2,4,8,16,32,60 → ~123s of backoff across 7 sleeps) plus any honored Retry-After hint, 8 attempts covers the common outage; a genuinely permanent fault (bad key, invalid_request, quota exhausted) is classified permanent_failure upstream and never consumes the budget.
Functions ¶
This section is empty.
Types ¶
type BackoffKind ¶
type BackoffKind int
BackoffKind selects which curve BackoffFor uses to compute the sleep before attempt n. The string form is what ir.RetryPolicy.Backoff carries (spec §6 — "exp" / "linear" / ""); the typed constants here are the engine- internal form. Unknown strings are an error at Merge time (Revision #8) — slice 1.4 doesn't yet validate this field, so the runtime is the line of defense.
const ( BackoffExp BackoffKind = iota // default — doubles each attempt, capped at Max BackoffLinear // adds Initial each attempt, capped at Max BackoffNone // always 0 (e.g. for tests where backoff would slow things down) )
type Policy ¶
type Policy struct {
Attempts int
Backoff BackoffKind
Initial time.Duration
Max time.Duration
NonRetryableExitCodes []int
}
Policy is the merged-down retry configuration the engine actually consults at dispatch time. ir.RetryPolicy is the wire/IR form (omitempty fields, string backoff name); Merge collapses default + per-step into this typed struct.
func Merge ¶
func Merge(def Policy, override *ir.RetryPolicy) (Policy, error)
Merge collapses def + an optional per-step override into a single Policy. Per spec §6 + plan Design question 6: per-step wins field-by-field; slice fields replace entirely (the field IS the slice, not a partial overlay).
A nil override still does work — it returns a deep-copy of def so the caller's returned Policy has its own NonRetryableExitCodes slice (Revision #6: protects against `p, _ := Merge(Default, nil); p.NonRetryableExitCodes[0] = 999` mutating Default for the next caller). A non-nil override's zero-valued fields (Attempts == 0, Initial == nil, etc.) leave the default in place — the override semantic is "set if non-zero" so the IR's `omitempty` shape doesn't silently zero a field the author didn't write.
Returns an error if override.Backoff is an unknown string (Revision #8) — callers MUST handle this; slice 2.5's interpreter halts the run with the error message at run-start, before the first dispatch.
func (Policy) BackoffFor ¶
BackoffFor returns the sleep duration to apply BEFORE attempt n (1-based). Attempt 1 always returns 0 (no preceding sleep). The curve is selected by p.Backoff and capped at p.Max:
- Exp: p.Initial × 2^(n-2) (attempt 2 = Initial, attempt 3 = 2×Initial, …)
- Linear: p.Initial × (n-1) (attempt 2 = Initial, attempt 3 = 2×Initial, …)
- None: 0 unconditionally
The engine's retry loop calls clk.Sleep(p.BackoffFor(attempt)) between dispatch calls.
func (Policy) EffectiveBackoff ¶
EffectiveBackoff is the sleep the engine retry loop applies before an attempt (1-based). It composes three things, in order:
- the curve (BackoffFor) — exp/linear/none, capped at Max;
- an optional server retryAfter hint (from a Retry-After header or a rate-limit reset time, surfaced via DispatchResult.RetryAfter) — when it exceeds the curve the loop waits the longer of the two, so a 1s curve doesn't burn attempts hammering a window the server said resets in 30s. The honored hint is capped at MaxHonoredRetryAfter;
- deterministic additive jitter in [0, jitterFraction·base) keyed on (seed, attempt), so parallel retries against the same window decorrelate.
seed is the node addressing path (engine NodeIntent.Path) — a pure function of the node graph (CLAUDE.md "node addressing is one pure function"), so the jitter is deterministic and resume-safe: it perturbs only the sleep duration, never the step inputs/outputs, and sleep durations are not journaled. No time.Now()/rand — the determinism invariant holds.
Jitter is additive-only, so the result is always >= max(curve, hint): we never undershoot a server's Retry-After.
func (Policy) IsRetryableExit ¶
IsRetryableExit reports whether a nonzero exit code triggers a retry under this policy. exit==0 returns false (success — nothing to retry). exit in NonRetryableExitCodes returns false (permanent — declared by the author). Everything else returns true (generic failure → retry per policy).
The engine's classifier (engine.ClassifyOutcome) reads this to decide retryable_failure vs permanent_failure per spec §6.