Skip to Content
ModelsRetryPolicy + error taxonomy

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-After

Full 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.

FieldDefaultEffect
max_attempts3Total attempts including the first.
initial_delay_s1.0Delay before the first retry.
multiplier2.0Each retry waits initial_delay_s * multiplier**n.
max_delay_s30.0Cap 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

ErrorRetried?
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.

Last updated on