RetryPolicy + error taxonomy
Model calls fail for transient reasons (rate limits, timeouts,
overloaded servers) and for permanent ones (bad API key, invalid
request). When an agent resolves a network model adapter
(AnthropicModel / OpenAIModel / LiteLLMModel), it wraps the
adapter’s calls in a typed retry policy that tells the two apart. You don’t write try / except for transient failures: the
loop retries with exponential backoff and gives up cleanly on
permanent errors.
Default policy
3 attempts · 1s → 2s exponential backoff, capped at 30s
full jitter (each wait drawn from 0 to that backoff) · honours provider Retry-AfterFull jitter is the AWS “exponential backoff and jitter” scheme: each delay is drawn uniformly from 0 up to the computed backoff, so many clients that failed together don’t retry together.
Roughly equivalent to:
from loomflow import Agent, Tuning
from loomflow.governance import RetryPolicy
agent = Agent(
"...",
model="claude-opus-4-7",
tuning=Tuning(retry_policy=RetryPolicy()), # the default policy
)For most users this is invisible. The agent just keeps working through provider blips.
Tuning the policy
from loomflow import Agent, Tuning
from loomflow.governance import RetryPolicy
# Aggressive — tolerates long provider outages
agent = Agent("...", model="claude-opus-4-7",
tuning=Tuning(retry_policy=RetryPolicy.aggressive()))
# Disabled — handle errors yourself
agent = Agent("...", model="claude-opus-4-7",
tuning=Tuning(retry_policy=RetryPolicy.disabled()))
# Custom
agent = Agent("...", model="claude-opus-4-7", tuning=Tuning(retry_policy=RetryPolicy(
max_attempts=5,
initial_delay_s=2.0,
multiplier=2.0,
max_delay_s=60.0,
jitter=0.2,
)))aggressive() is 6 attempts starting at 0.5 s, capped at 60 s;
disabled() is a single attempt.
| Field | Default | Effect |
|---|---|---|
max_attempts | 3 | Total attempts including the first. |
initial_delay_s | 1.0 | Delay before the first retry. |
multiplier | 2.0 | Each retry waits initial_delay_s * multiplier**n. |
max_delay_s | 30.0 | Cap on the exponential growth. |
jitter | "full" | "full": each delay is drawn uniformly from [0, backoff]. A float applies that ±fraction instead (0.1 = ±10%, the pre-0.17 default); 0 gives deterministic delays, handy in tests. |
A provider’s Retry-After hint (retry-after-ms, or Retry-After in
seconds or as an HTTP date) is honored as a floor — it’s applied
automatically by the backoff calculation, not a constructor field. A
hint longer than max_delay_s ends the retries instead of sleeping
that long: the error is raised so a fallback
model can take over.
Error taxonomy
Adapters classify provider exceptions into a typed hierarchy:
LoomError
├── ModelError (base for any model issue)
│ ├── TransientModelError (retried)
│ │ ├── RateLimitError (429; retry-after honored)
│ │ └── ... (5xx, network timeouts, connection resets)
│ └── PermanentModelError (NOT retried)
│ ├── AuthenticationError (401)
│ ├── InvalidRequestError (400 — bad prompt, missing field)
│ └── ContentFilterError (provider safety filter)
├── OutputValidationError (output_schema= validation failed)
└── ...OutputValidationError is a direct subclass of LoomError, not a
ModelError — the model call itself succeeded; only the typed-output
validation failed.
classify_model_error(exc) is the helper the adapters use; you can
call it from your own code:
from loomflow.core.errors import RateLimitError
from loomflow.governance import classify_model_error
try:
...
except Exception as exc:
typed = classify_model_error(exc)
if isinstance(typed, RateLimitError):
...What gets retried
| Error | Retried? |
|---|---|
RateLimitError (429) | yes, with Retry-After honored |
TransientModelError (408, 409, 5xx, an overloaded_error / api_error event inside an Anthropic stream, network blips, timeouts) | yes, while streaming only until the first chunk arrives |
PermanentModelError (401, 400, content filter, an OpenAI insufficient_quota 429) | no. Fail fast |
OutputValidationError (schema validation failed) | no. Handled separately |
For OutputValidationError the framework follows a different path:
it appends the validation message to the conversation and asks the
model to retry, up to a separate output_validation_retries limit
(a run() argument, default 1).
What about tool errors?
Tool errors are not retried at the model layer. Each tool’s exception
is captured in its ToolResult(ok=False, error=...); the model sees
the error in the next turn and can decide whether to retry. To retry
at the framework level, wrap the tool body yourself:
@tool
async def fetch(url: str) -> str:
"""Fetch a URL with up to 3 retries."""
for attempt in range(3):
try:
return await client.get(url)
except httpx.NetworkError:
if attempt == 2:
raise
await asyncio.sleep(2**attempt)Observability
Retries happen inside the model call, so one model span
(loom.model.complete / loom.model.stream, exported to
OpenTelemetry as chat {model}) covers every attempt and the waits
between them; its duration and the gen_ai.client.inference.duration
metric include them. Attempts aren’t logged or counted on the span.
When the retries run out, the final error is recorded on the span with
error.type set to its class (RateLimitError, …) and an ERROR
status. See Telemetry.
Don’t double-retry. The SDK clients loomflow builds for you are
created with max_retries=0, so the framework’s RetryPolicy owns
the retry loop. If you pass your own client (AnthropicModel(client=...),
OpenAIModel(client=...)), create it with max_retries=0 as well.
Otherwise you compound 3×3 = 9 attempts on a single call and the
user-visible latency explodes.