Configuring Tasks

Retries, per-attempt timeouts, and circuit breakers via TaskConfig.

Every Task can carry a TaskConfig that controls how it retries, how long each attempt may run, and whether a circuit breaker guards it.


Configuration & Retries

Tasks can be configured with retry strategies to handle transient failures. The TaskConfig struct allows you to specify the retry behavior.

Retry Strategy Examples

Retry with backoff between attempts

sequenceDiagram participant W as Workflow participant T as Task W->>T: Execute T-->>W: Fail Note over W: Wait (backoff) W->>T: Retry 1 T-->>W: Fail Note over W: Wait (longer) W->>T: Retry 2 T-->>W: Success ✓

Fixed Retry

Retry a fixed number of times with a constant delay between attempts.

Fixed retry config
TaskConfig::default()
    .with_fixed_retry(3, Duration::from_secs(1))

Exponential Backoff

Retry with exponentially increasing delays, useful for rate-limited APIs.

Exponential backoff config
TaskConfig::default()
    .with_exponential_retry(5)

Minimal Config

Fast execution with minimal retry overhead for reliable operations.

Minimal config
TaskConfig::minimal()

Per-Attempt Timeout

Bound each attempt with a fresh deadline. Composes with any retry mode.

Attempt timeout config
TaskConfig::default()
    .with_exponential_retry(3)
    .with_attempt_timeout(Duration::from_secs(2))

How attempt timeouts compose with retries

When attempt_timeout is set, each attempt inside run_with_retries is wrapped in tokio::time::timeout. An expired attempt produces a CanoError::Timeout, which is fed through the same retry path as any other failure — so the configured RetryMode decides whether to retry. The deadline resets on every attempt, and retry exhaustion still surfaces as CanoError::RetryExhausted wrapping the underlying timeout context.

Wiring a Circuit Breaker

A CircuitBreaker can be attached to a task's config via TaskConfig::with_circuit_breaker(Arc::clone(&breaker)). The retry loop consults it before each attempt; an open breaker short-circuits the whole loop with CanoError::CircuitOpen (returned raw, not wrapped in RetryExhausted), so a dependency that is already down is not hammered. Share one Arc<CircuitBreaker> across every task that hits the same dependency so they trip together.

Attaching a breaker to a task config
fn build_config(breaker: Arc<CircuitBreaker>) -> TaskConfig {
    TaskConfig::default()
        .with_exponential_retry(3)
        .with_circuit_breaker(breaker)
}

The breaker itself — its Closed → Open { until } → HalfOpen state machine, CircuitPolicy, the lazy Open → HalfOpen transition, and the manual try_acquire / record_success / record_failure RAII API — is documented in the Resilience guide.

Real-World Example: API Client with Retry

🌐 API client with exponential backoff
use cano::prelude::*;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum State { Call, Complete }

#[derive(Clone)]
struct ApiClientTask {
    endpoint: String,
}

#[task(state = State)]
impl ApiClientTask {
    fn config(&self) -> TaskConfig {
        // Exponential backoff for API rate limiting
        TaskConfig::default()
            .with_exponential_retry(5)
    }

    async fn run(&self, res: &Resources) -> Result<TaskResult<State>, CanoError> {
        println!("📡 Calling API: {}", self.endpoint);

        let store = res.get::<MemoryStore, _>("store")?;

        // Replace this with your HTTP client of choice (reqwest, hyper, etc.)
        let data = String::new();

        store.put("api_response", data)?;
        println!("✅ API call successful");

        Ok(TaskResult::Single(State::Complete))
    }
}