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.
#[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.
retry_attempts,retry_delay_ms,retry_backoffreference — the rest of the retry-policy knobscloacina_workflow::RetryPolicy— the underlying policy struct- Source:
crates/cloacina-workflow/src/retry.rs