Retry strategies¶
Production model calls fail. Rate limits, gateway timeouts, transient 5xx, occasional content-policy refusals on retryable inputs. Tulip's retry posture is: automate what's transient; surface what's not.
The default behaviour¶
Out of the box, an Agent(...) with no retry hook does not retry
model calls. Model retries are hook-driven (a hook sets
event.retry = True on the after-model-call event to trigger a
re-invocation), so without ModelRetryHook a provider exception
propagates: the loop exits and result.stop_reason == "error" (the
underlying exception is re-raised). The error detail lands on
result.error.
So if you want resilience to transient model failures, add the hook below — it's opt-in, not automatic.
Configurable retry — ModelRetryHook¶
For production agents you usually want explicit policy:
from tulip.hooks.builtin.retry import ModelRetryHook
from tulip.agent import Agent
agent = Agent(
model="anthropic:claude-sonnet-4-6",
tools=[...],
hooks=[
ModelRetryHook(
max_retries=3,
initial_delay=0.5, # seconds, first retry delay
max_delay=8.0,
backoff_factor=2.0, # exponential multiplier per attempt
retry_on_empty=True, # retry when the model returns no content
),
],
)
The hook implements on_after_model_call: it inspects the response and,
when the model came back empty (no content and no tool calls),
sets event.retry = True to trigger a re-invocation after an
exponential-backoff delay. The runtime observes event.retry and
re-runs the model call — same state, same messages, fresh request — up
to max_retries times.
Scope: empty responses
ModelRetryHook retries empty model responses — it is not a
generic HTTP-error/rate-limit retrier. For provider rate limits or
5xx, retry inside your model client or wrap the whole run (see
When to widen the retry net).
Tool-level retry¶
Tools fail too, and the failure mode is usually different — a downstream HTTP call, a transient DB error, a JSON-decode glitch. Three options:
@tool
def fetch_alert(alert_id: str) -> dict:
"""Fetch the details of a SOC alert."""
return siem.get(alert_id)
-
Let the loop handle it. When
fetch_alertraises, the SDK captures the exception, emits aToolCompleteEventwith itserrorfield set (no separate error-event type), records the failure in state, and feeds the error message to the next Think. The model can then decide whether to retry the call, try a different tool, or give up — same as a human would. -
Retry inside the tool. For idempotent operations, wrap with
tenacityor the like and retry transparently:
from tenacity import retry, stop_after_attempt, wait_exponential
@tool
@retry(stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=0.5))
def fetch_alert(alert_id: str) -> dict: ...
- Cooperative cancellation. Long-running tools should poll for cancellation from their async context (or check a shared flag) so the agent can give up cleanly when the user cancels or a budget hook fires.
Idempotent retry¶
This is the SDK-distinctive bit. If a tool is tagged
@tool(idempotent=True) and the model retries the same call, the
Execute node dedupes inside the loop — the body never runs the
second time, and the cached receipt is returned.
This means you can let the model loop, panic, and retry without
isolating the same host twice (or paging the on-call twice). The
Execute hash is (tool_name,
kwargs), so semantically-different calls aren't accidentally
deduped.
See Idempotency for the full contract.
Termination interactions¶
Retries don't bypass termination=. The retry hook re-runs Think;
the router checks the termination algebra after every node. If
your composite includes MaxIterations(10), ten iterations is
ten iterations whether or not Think retried inside one of them.
For wall-clock budgets, use TimeLimit(seconds=60). The clock
includes retry waits.
When to widen the retry net¶
| Scenario | Strategy |
|---|---|
| Flaky empty completions | ModelRetryHook(max_retries=5) (retries empty responses) |
| Predictable rate limits / 5xx | retry inside your model client, or wrap the run; ModelRetryHook does not cover these |
| Provider / API-key failover | CredentialPoolModel(pool=CredentialPool([...]), build_model=…) rotates keys/endpoints on rate-limit or outage |
| Customer-facing agents | wrap the whole agent in your own outer retry; the inner agent treats one client request = one run |
See also¶
- Hooks — full hook system, including
ModelRetryHook. - Idempotency — why marking tools idempotent is a retry safety valve.
- Termination — how retries interact with stop conditions.
- Models — provider-specific retry semantics.