Skip to main content
Chevere Workflow provides built-in retry capabilities for jobs. When a job fails, it can automatically retry with configurable timeout, delay, and maximum attempts.

Basic retry configuration

Use withRetry() to configure retry behavior:
use function Chevere\Workflow\run;
use function Chevere\Workflow\sync;
use function Chevere\Workflow\workflow;

$workflow = workflow(
    job1: sync(new UnstableAction())
        ->withRetry(
            timeout: 5000,      // 5 seconds timeout
            maxAttempts: 3,     // Try up to 3 times
            delay: 1000         // Wait 1 second between attempts
        ),
);

$run = run($workflow);

Retry parameters

Max attempts

The maximum number of times to attempt the job:
src/RetryPolicy.php
public function __construct(
    #[_int(min: 0)]
    private int $timeout = 0,
    #[_int(min: 1)]
    private int $maxAttempts = 1,
    #[_int(min: 0)]
    private int $delay = 0
) {
    assertArguments();
}
  • Default: 1 (no retry)
  • Minimum: 1 (must attempt at least once)
  • Units: Number of attempts
// Retry up to 5 times
$job = sync(new MyAction())
    ->withRetry(maxAttempts: 5);

Timeout

Maximum time (in milliseconds) to wait for each attempt:
  • Default: 0 (no timeout)
  • Minimum: 0
  • Units: Milliseconds
// Timeout after 3 seconds
$job = sync(new MyAction())
    ->withRetry(
        timeout: 3000,
        maxAttempts: 3
    );
If a job exceeds the timeout, it will be cancelled and may retry if attempts remain. The timeout applies to each individual attempt, not the total retry duration.

Delay

Time (in milliseconds) to wait between retry attempts:
  • Default: 0 (retry immediately)
  • Minimum: 0
  • Units: Milliseconds
// Wait 2 seconds between retries
$job = sync(new MyAction())
    ->withRetry(
        maxAttempts: 3,
        delay: 2000
    );

Retry execution flow

The Runner implements retry logic with timeout and delay support:
src/Runner.php
$retryPolicy = $job->retryPolicy();
$maxAttempts = $retryPolicy->maxAttempts();
$delay = $retryPolicy->delay();
$timeout = $retryPolicy->timeout();
$cancellation = $timeout > 0
    ? new TimeoutCancellation($timeout)
    : null;
$lastException = null;
$response = null;

for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
    $currentAttempt = $attempt;

    try {
        if ($cancellation !== null) {
            $response = await(
                [async(fn (): mixed => $action->__invoke(...$arguments))],
                cancellation: $cancellation
            )[0];
        } else {
            $response = $action->__invoke(...$arguments);
        }
        $lastException = null;
        break;
    } catch (Throwable $e) {
        $lastException = $e;
        if ($e instanceof CancelledException) {
            break;
        }
        if ($attempt < $maxAttempts && $delay > 0) {
            delay($delay);
        }
    }
}

if ($lastException !== null) {
    throw new RunnerException(
        name: $name,
        job: $job,
        throwable: $lastException,
        attempt: $currentAttempt,
    );
}

Retry success example

An action that succeeds on the Nth attempt:
tests/src/TestActionWorksOnNAttempt.php
final class TestActionWorksOnNAttempt extends Action
{
    private int $attemptCount = 0;

    public function __construct(
        private int $successOnAttempt
    ) {}

    public function __invoke(): array
    {
        ++$this->attemptCount;

        if ($this->attemptCount < $this->successOnAttempt) {
            throw new LogicException(
                "Attempt {$this->attemptCount} failed, required attempt {$this->successOnAttempt}"
            );
        }

        return [
            'attempt' => $this->attemptCount,
        ];
    }
}
Using this action:
tests/RunnerTest.php
// Succeeds on 5th attempt
$workflow = workflow(
    job1: sync(new TestActionWorksOnNAttempt(5))
        ->withRetry(maxAttempts: 5),
);

$run = run($workflow);
assert($run->response('job1', 'attempt')->int() === 5);

Retry failure example

When retries are exhausted, a RunnerException is thrown:
tests/RunnerTest.php
use Chevere\Workflow\Exceptions\RunnerException;

$workflow = workflow(
    job1: sync(new TestActionWorksOnNAttempt(5))
        ->withRetry(maxAttempts: 4), // Not enough attempts!
);

try {
    run($workflow);
} catch (RunnerException $e) {
    echo $e->getMessage();
    // [job1]: [4/4] Attempt 4 failed, required attempt 5
    
    echo $e->attempt;      // 4
    echo $e->name;         // job1
    echo $e->job;          // JobInterface instance
    echo $e->throwable;    // Original exception
}

Timeout handling

Jobs that exceed the timeout are cancelled:
tests/RunnerTest.php
$workflow = workflow(
    job1: sync(new SlowAction(), seconds: 2.0)
        ->withRetry(
            timeout: 1000,     // 1 second timeout
            maxAttempts: 3
        ),
);

try {
    run($workflow);
} catch (RunnerException $e) {
    // [job1]: [1/3] The operation was cancelled
}
When a job times out, it immediately stops and may retry if maxAttempts allows. The exception type will be CancelledException.

Delay between retries

The delay is applied between failed attempts:
tests/RunnerTest.php
$workflow = workflow(
    job1: sync(new TestActionWorksOnNAttempt(2))
        ->withRetry(
            maxAttempts: 2,
            delay: 1000  // 1 second delay
        ),
);

$startTime = microtime(true);
$run = run($workflow);
$elapsed = microtime(true) - $startTime;

// First attempt fails, waits 1 second, second attempt succeeds
assert($elapsed >= 1.0);

Retry policy object

The retry policy is stored in the job:
src/Job.php
private RetryPolicyInterface $retryPolicy;

public function __construct(
    ActionInterface|string|callable $_,
    mixed ...$argument
) {
    // ...
    $this->retryPolicy = new RetryPolicy();
}

public function withRetry(
    int $timeout = 0,
    int $maxAttempts = 1,
    int $delay = 0
): JobInterface {
    $new = clone $this;
    $new->retryPolicy = new RetryPolicy($timeout, $maxAttempts, $delay);

    return $new;
}
Access the retry policy:
$job = sync(new MyAction())
    ->withRetry(
        timeout: 5000,
        maxAttempts: 3,
        delay: 1000
    );

$policy = $job->retryPolicy();
echo $policy->timeout();      // 5000
echo $policy->maxAttempts();  // 3
echo $policy->delay();        // 1000

Complex retry scenario

Multiple async jobs with different retry policies:
tests/RunnerTest.php
$workflow = workflow(
    job1: async(new TestActionWorksOnNAttempt(2))
        ->withRetry(
            timeout: 100,
            maxAttempts: 2,
        ),
    job2: async(new TestActionWorksOnNAttempt(3))
        ->withRetry(
            timeout: 100,
            maxAttempts: 3,
        ),
    job3: sync(
        function (int $res1, int $res2) {
            return ['res1' => $res1, 'res2' => $res2];
        },
        res1: response('job1', 'attempt'),
        res2: response('job2', 'attempt'),
    )
);

$run = run($workflow);
assert($run->response('job3', 'res1')->int() === 2);
assert($run->response('job3', 'res2')->int() === 3);

Error message format

The RunnerException includes attempt information when maxAttempts > 1:
src/Exceptions/RunnerException.php
protected function template(): string
{
    if ($this->maxAttempts > 1) {
        return '[%name%]: [' . $this->attempt . '/' . $this->maxAttempts . '] %message%';
    }

    return '[%name%]: %message%';
}
Examples:
  • Single attempt: [job1]: Connection failed
  • Multiple attempts: [job1]: [3/5] Connection failed

Best practices

1

Set appropriate timeouts

Choose timeout values based on expected operation duration. Too short causes unnecessary failures; too long wastes time.
2

Use exponential backoff for delays

For production use, consider implementing exponential backoff by wrapping retry logic or using increasing delays.
3

Limit retry attempts

Don’t set maxAttempts too high. 3-5 attempts is usually sufficient for transient failures.
4

Only retry transient failures

Configure retries for operations that may fail temporarily (network requests, database connections), not logic errors.
5

Log retry attempts

Implement logging in your actions to track retry behavior:
public function __invoke(): array
{
    $this->logger->info('Attempting operation', [
        'attempt' => $this->attemptCount,
    ]);
    // ...
}

Use cases

$job = async(new FetchApi())
    ->withRetry(
        timeout: 10000,    // 10 second timeout
        maxAttempts: 3,    // Try 3 times
        delay: 2000        // Wait 2 seconds between attempts
    );
$job = sync(new DatabaseInsert())
    ->withRetry(
        timeout: 5000,
        maxAttempts: 5,
        delay: 500
    );
$job = async(new UploadFile())
    ->withRetry(
        timeout: 30000,    // 30 second timeout for large files
        maxAttempts: 3,
        delay: 1000
    );
$job = async(new PaymentProcessor())
    ->withRetry(
        timeout: 15000,
        maxAttempts: 2,    // Only retry once for payments
        delay: 5000
    );

Build docs developers (and LLMs) love