Skip to content

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)
  1. Let the loop handle it. When fetch_alert raises, the SDK captures the exception, emits a ToolCompleteEvent with its error field 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.

  2. Retry inside the tool. For idempotent operations, wrap with tenacity or 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: ...
  1. 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.

@tool(idempotent=True)
def isolate_host(host_id: str) -> dict: ...

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.