Retry Policy
On this page
Why retries are essential
In a distributed system, transient errors are inevitable: network timeouts, exceeded rate limits, temporarily unavailable services. None of these failures indicate a bug in your code, yet any one of them can cause a process to fail if nothing absorbs the shock. A retry policy lets your activities survive these errors without manual intervention, with the platform automatically relaunching them according to the configuration you define for each activity.
Configuring a retry policy
The @workflows.activity() decorator accepts parameters that govern this behavior. Here is an external API call activity properly armed for resilience.
from datetime import timedelta
import mistralai.workflows as workflows
@workflows.activity(
start_to_close_timeout=timedelta(minutes=5),
retry_policy_max_attempts=3,
retry_policy_backoff_coefficient=2.0,
)
async def call_external_api(url: str, data: dict) -> dict:
"""Calls an external API with automatic retry."""
async with httpx.AsyncClient(timeout=60) as client:
response = await client.post(url, json=data)
response.raise_for_status()
return response.json()
Three parameters, three complementary roles.
-
start_to_close_timeout— Maximum duration for a single execution attempt. If the activity does not complete within this timeframe, it is considered failed and a new attempt is launched. -
retry_policy_max_attempts— Maximum number of attempts. The default value depends on the platform. After this number of attempts, the activity fails definitively and the workflow receives an error. -
retry_policy_backoff_coefficient— Multiplier for the delay between attempts. With a coefficient of 2.0, delays double with each attempt (1s, 2s, 4s, 8s…). This is exponential backoff.
This backoff mechanism deserves attention because its purpose isn’t just to wait: it gives the struggling service time to recover instead of hammering it. The progression of a call that eventually succeeds illustrates this clearly.
Attempt 1: immediate execution
→ Failure (network timeout)
→ Wait 1 second
Attempt 2: execution
→ Failure (service unavailable)
→ Wait 2 seconds (1 × 2.0)
Attempt 3: execution
→ Failure (rate limit)
→ Wait 4 seconds (2 × 2.0)
Attempt 4: execution
→ Success!
Facing a “429 Too Many Requests” response, this mechanism is particularly effective: a policy that retried immediately would only sustain the saturation, whereas increasing spacing eventually drops below the provider’s threshold.
Never omit start_to_close_timeout
This timeout is critical and should appear on every activity, without exception. Without it, an activity that gets stuck—deadlock, hanging connection, service that accepts the request but never responds—waits indefinitely and freezes the entire workflow. Worse: it triggers no retry, since from its perspective it hasn’t failed yet.
# BAD PRACTICE: no timeout
@workflows.activity()
async def dangerous_activity(params: dict) -> dict:
# If the service never responds, the activity waits indefinitely
async with httpx.AsyncClient() as client:
response = await client.get("https://api.lente.example.com/data")
return response.json()
# GOOD PRACTICE: explicit timeout
@workflows.activity(
start_to_close_timeout=timedelta(minutes=2)
)
async def safe_activity(params: dict) -> dict:
async with httpx.AsyncClient(timeout=90) as client:
response = await client.get("https://api.lente.example.com/data")
return response.json()
To set the value, first measure the normal execution time of the activity, then add a margin of two to three times to cover slow cases. Finally, ensure the internal HTTP client timeout remains lower than the start_to_close_timeout—in the example above, 90 seconds versus 2 minutes—so that the error propagates cleanly from your code rather than being cut off by the platform.
Idempotence: a non-negotiable condition
Since an activity can be replayed multiple times with the same parameters, it must produce the same effect regardless of the number of executions. This property is called idempotence, and its absence turns a retry policy into an incident generator.
@workflows.activity(retry_policy_max_attempts=3)
async def add_credit(user_id: str, amount: float) -> dict:
"""DANGEROUS: if retried, credit is added multiple times!"""
async with httpx.AsyncClient() as client:
response = await client.post(
f"https://api.billing.com/credits/add",
json={"user_id": user_id, "amount": amount}
)
return response.json()
The dreaded scenario is common: the billing API processes the request correctly, but the response gets lost on the network. The activity concludes failure, the platform retries, and the customer gets credited twice. An idempotency key eliminates the problem by giving the remote service a way to recognize an already-processed request.
@workflows.activity(retry_policy_max_attempts=3)
async def add_credit(user_id: str, amount: float, idempotency_key: str) -> dict:
"""CORRECT: idempotency key prevents duplication."""
async with httpx.AsyncClient() as client:
response = await client.post(
f"https://api.billing.com/credits/add",
json={
"user_id": user_id,
"amount": amount,
"idempotency_key": idempotency_key
}
)
return response.json()
Generate the key in the workflow using workflow.uuid4(), which guarantees it remains identical from one replay to another—a classic uuid.uuid4() would produce a new value on each attempt and cancel all protection. When the remote service doesn’t offer this mechanism, two other paths exist: upsert in the database, using INSERT ... ON CONFLICT UPDATE instead of INSERT alone, or pre-check verification, which involves querying the state before acting.
Three configuration profiles
Values calibrate according to the nature of the operation. A payment or notification must succeed quickly or fail quickly: short timeout, three attempts, moderate backoff.
@workflows.activity(
start_to_close_timeout=timedelta(seconds=30),
retry_policy_max_attempts=3,
retry_policy_backoff_coefficient=2.0,
)
async def process_payment(params: dict) -> dict:
# ...
pass
Long-running processing, such as report generation or heavy inference, inverts the logic: each attempt costs significant compute time, so you authorize fewer attempts with wider spacing.
@workflows.activity(
start_to_close_timeout=timedelta(minutes=15),
retry_policy_max_attempts=2,
retry_policy_backoff_coefficient=3.0,
)
async def generate_ml_report(params: dict) -> dict:
# ...
pass
Facing an unstable third-party API subject to rate limiting, you finally assume persistence: five attempts, even if it spans several minutes.
@workflows.activity(
start_to_close_timeout=timedelta(minutes=2),
retry_policy_max_attempts=5,
retry_policy_backoff_coefficient=2.0,
)
async def call_third_party_api(params: dict) -> dict:
# ...
pass
Key takeaways
- Always configure
start_to_close_timeoutto avoid blocking activities - Exponential backoff (
retry_policy_backoff_coefficient) increases the delay between attempts retry_policy_max_attemptsdefines the maximum number of attempts before definitive failure- Activities with retry must be idempotent—same result if executed multiple times
- Use idempotency keys or upserts to guarantee idempotence
- Adapt timeouts and retry counts to the operation type (fast vs. long, stable vs. unstable)