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:
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:
$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:
// 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:
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:
$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:
$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:
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:
$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 );
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
Set appropriate timeouts
Choose timeout values based on expected operation duration. Too short causes unnecessary failures; too long wastes time.
Use exponential backoff for delays
For production use, consider implementing exponential backoff by wrapping retry logic or using increasing delays.
Limit retry attempts
Don’t set maxAttempts too high. 3-5 attempts is usually sufficient for transient failures.
Only retry transient failures
Configure retries for operations that may fail temporarily (network requests, database connections), not logic errors.
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
);
External service integration
$job = async ( new PaymentProcessor ())
-> withRetry (
timeout : 15000 ,
maxAttempts : 2 , // Only retry once for payments
delay : 5000
);