Skip to content

Conditional Retries

By default, #[task] retries every failure up to retry_attempts. That’s the right call for transient errors (network, lock contention) and the wrong call for permanent ones (validation, permissions). The retry_condition attribute on the task macro lets you decide which.

The retry-condition machinery has shipped since the original retry-policy work; this guide pins the vocabulary, shows the supported patterns, and points at the matching examples.

retry_condition accepts a string literal. Four forms:

value meaning
"all" (or omitted) Retry on every error. Default behavior.
"never" Never retry, regardless of retry_attempts.
"transient" Retry only on transient-flavored errors (timeout, “connection”, “network”, “temporary”, “unavailable”, “busy”, “overloaded”, “rate limit”).
"foo,bar" (comma list) Retry only when the error message contains any of the listed substrings (case-insensitive).

The transient matcher is intentionally string-based on the error’s Display impl. That keeps the policy serializable for packaged workflows — no closure or trait-object plumbing.

Watch out: unknown strings fall back silently

Section titled “Watch out: unknown strings fall back silently”

Neither retry attribute rejects an unrecognized string at compile time:

  • retry_condition: any value other than the keywords "all", "never", or "transient" is parsed as a comma-separated substring pattern list — that is how the pattern form works, but it also means a typo is not an error. retry_condition = "transiant" becomes a pattern that matches errors containing the literal text “transiant”, which in practice means the task never retries.
  • retry_backoff: only "fixed", "linear", and "exponential" are recognized. Any other string (e.g. "exponental") silently falls back to exponential — no error, no warning.

If retries behave unexpectedly, re-check these two strings first. Both behaviors are implemented in generate_retry_policy_code.

#[task(
retry_attempts = 3,
retry_delay_ms = 100,
retry_condition = "transient",
)]
async fn flaky_api_call(context: &mut Context<Value>) -> Result<(), TaskError> {
// ... returning an error whose message contains "connection refused"
// will be retried; a "validation failed" error will not.
}
#[task(
retry_attempts = 3,
retry_condition = "never",
)]
async fn validation_check(context: &mut Context<Value>) -> Result<(), TaskError> {
// No matter what the error is, this task will fail after a single
// attempt. `retry_attempts = 3` is a no-op here.
}

A runnable end-to-end example lives at examples/features/workflows/conditional-retries.

import cloaca
with cloaca.WorkflowBuilder("flaky_pipeline") as builder:
@cloaca.task(
retry_attempts=3,
retry_delay_ms=100,
retry_condition="transient",
)
def flaky_api_call(context):
raise RuntimeError("connection refused (simulated)") # retried
@cloaca.task(
retry_attempts=3,
retry_condition="never",
)
def validation_check(context):
raise RuntimeError("invalid input") # not retried

End-to-end coverage: tests/python/test_scenario_33_retry_condition.py.

When the predefined sets don’t fit, use a comma-separated pattern list:

#[task(
id = "scrape_endpoint",
retry_attempts = 5,
retry_condition = "rate limit,5xx,deadline exceeded",
)]

Each pattern is matched as a case-insensitive substring against the error’s Display impl. If any pattern matches, the task retries.

retry_attempts is still the upper bound — retry_condition only decides whether to schedule the next attempt within that budget.

attempt failed
│
├── attempt < retry_attempts? ── no ──► Failed
│
└── yes
│
└── retry_condition.should_retry(error)? ── no ──► Failed
│
└── yes ──► schedule retry with backoff

So retry_attempts = 3, retry_condition = "never" collapses to “fail on first error.” retry_attempts = 1, retry_condition = "all" means “retry once on any error” — same as before.