Chevere Workflow provides comprehensive error handling through exceptions, retry policies, and conditional execution. Understanding how errors propagate through workflows is essential for building robust applications.
Exception hierarchy
Workflow exceptions extend a common base class:
src/Exceptions/WorkflowException.php
abstract class WorkflowException extends Exception
{
public readonly string $name ;
public readonly JobInterface $job ;
public readonly Throwable $throwable ;
public function __construct (
string $name ,
JobInterface $job ,
Throwable $throwable ,
) {
$message = ( string ) message (
$this -> template (),
name : $name ,
message : $throwable -> getMessage (),
caller : $job -> caller ()
);
parent :: __construct ( message : $message , previous : $throwable );
$this -> name = $name ;
$this -> job = $job ;
$this -> throwable = $throwable ;
$this -> file = $job -> caller () -> file ();
$this -> line = $job -> caller () -> line ();
}
protected function template () : string
{
return '[%name%]: %message%' ;
}
}
RunnerException
Thrown when a job fails during runtime execution:
src/Exceptions/RunnerException.php
final class RunnerException extends WorkflowException
{
public readonly int $attempt ;
private int $maxAttempts ;
public function __construct (
string $name ,
JobInterface $job ,
Throwable $throwable ,
int $attempt ,
) {
$this -> attempt = $attempt ;
$this -> maxAttempts = $job -> retryPolicy () -> maxAttempts ();
parent :: __construct (
name : $name ,
job : $job ,
throwable : $throwable ,
);
}
protected function template () : string
{
if ( $this -> maxAttempts > 1 ) {
return '[%name%]: [' . $this -> attempt . '/' . $this -> maxAttempts . '] %message%' ;
}
return '[%name%]: %message%' ;
}
}
JobsException
Thrown for static workflow construction errors:
src/Exceptions/JobsException.php
final class JobsException extends WorkflowException
{
}
Catching job failures
When a job throws an exception, it’s wrapped in a RunnerException:
use Chevere\Workflow\Exceptions\ RunnerException ;
use Exception ;
// Action that throws
class TestActionThrows extends Action
{
public function __invoke () : void
{
throw new Exception ( 'Test exception' , 666 );
}
}
$workflow = workflow (
job1 : sync ( new TestActionThrows ()),
);
try {
run ( $workflow );
} catch ( RunnerException $e ) {
echo $e -> getMessage (); // [job1]: Test exception
echo $e -> name ; // job1
echo $e -> throwable -> getCode (); // 666
echo get_class ( $e -> throwable ); // Exception
}
Accessing exception details
The RunnerException provides access to:
try {
run ( $workflow );
} catch ( RunnerException $e ) {
// Job name
$jobName = $e -> name ;
// The job that failed
$job = $e -> job ;
// Original exception
$original = $e -> throwable ;
$originalMessage = $e -> throwable -> getMessage ();
$originalCode = $e -> throwable -> getCode ();
// Attempt number (for retries)
$attempt = $e -> attempt ;
// File and line where job was defined
$file = $e -> getFile ();
$line = $e -> getLine ();
// Access previous exception (the original)
$previous = $e -> getPrevious ();
}
Error handling with retries
Combine retry policies with error handling:
$workflow = workflow (
fetch : async ( new FetchUrl ())
-> withRetry (
timeout : 5000 ,
maxAttempts : 3 ,
delay : 1000
),
);
try {
$run = run ( $workflow , url : 'https://example.com' );
$content = $run -> response ( 'fetch' ) -> string ();
} catch ( RunnerException $e ) {
// All 3 attempts failed
$logger -> error ( 'Failed to fetch URL' , [
'job' => $e -> name ,
'attempt' => $e -> attempt ,
'error' => $e -> throwable -> getMessage (),
]);
}
Conditional error handling
Use conditional execution to handle errors gracefully:
$workflow = workflow (
validate : async ( new ValidateInput ()),
process : async ( new ProcessData ())
-> withRunIf ( response ( 'validate' , 'isValid' )),
logError : async ( new LogError ())
-> withRunIfNot ( response ( 'validate' , 'isValid' )),
);
$run = run ( $workflow , input : $data );
if ( $run -> skip () -> contains ( 'process' )) {
// Validation failed, error was logged
$error = $run -> response ( 'validate' , 'error' ) -> string ();
echo "Validation failed: { $error } \n " ;
} else {
// Success
$result = $run -> response ( 'process' ) -> array ();
}
Handling missing dependencies
Dependency injection errors throw ContainerException:
use Chevere\Container\Exceptions\ ContainerException ;
$workflow = workflow (
job1 : sync ( ActionWithDependency :: class ),
);
try {
run ( $workflow ); // No container with dependencies
} catch ( ContainerException $e ) {
echo $e -> getMessage ();
// Failed to resolve dependencies for `ActionWithDependency`:
// Missing required argument(s): `dependency`
}
Handling timeout errors
Timeout errors result in CancelledException:
use Amp\ CancelledException ;
use Chevere\Workflow\Exceptions\ RunnerException ;
$workflow = workflow (
slow : sync ( new SlowAction ())
-> withRetry (
timeout : 1000 ,
maxAttempts : 2
),
);
try {
run ( $workflow );
} catch ( RunnerException $e ) {
if ( $e -> throwable instanceof CancelledException ) {
echo "Job timed out after { $e -> job -> retryPolicy () -> timeout ()}ms \n " ;
}
}
Handling skipped job access
Attempting to access a skipped job’s response throws OutOfBoundsException:
use OutOfBoundsException ;
$workflow = workflow (
conditional : async ( new MyAction ())
-> withRunIf ( variable ( 'enabled' )),
);
$run = run ( $workflow , enabled : false );
try {
$response = $run -> response ( 'conditional' );
} catch ( OutOfBoundsException $e ) {
echo "Job was skipped and has no response \n " ;
}
// Better approach: check first
if ( ! $run -> skip () -> contains ( 'conditional' )) {
$response = $run -> response ( 'conditional' );
}
Error recovery workflows
Design workflows that gracefully handle and recover from errors:
$workflow = workflow (
// Try primary data source
primary : async ( new FetchFromPrimary ())
-> withRetry (
timeout : 3000 ,
maxAttempts : 2 ,
delay : 500
),
// Fallback to secondary if primary unavailable
secondary : async ( new FetchFromSecondary ())
-> withRunIf (
fn ( RunInterface $run ) => $run -> skip () -> contains ( 'primary' )
),
// Use whichever source succeeded
process : async (
function ( mixed $data ) : array {
return processData ( $data );
},
data : fn ( RunInterface $run ) =>
$run -> skip () -> contains ( 'primary' )
? $run -> response ( 'secondary' ) -> mixed ()
: $run -> response ( 'primary' ) -> mixed ()
),
);
This pattern requires careful handling as closures in arguments are not currently supported. Use response references instead.
Centralized error handling
Implement a consistent error handling strategy:
class WorkflowRunner
{
public function __construct (
private LoggerInterface $logger ,
private ErrorReporter $reporter ,
) {}
public function execute ( WorkflowInterface $workflow , array $variables ) : RunInterface
{
try {
return run ( $workflow , ... $variables );
} catch ( RunnerException $e ) {
$this -> handleRunnerException ( $e );
throw $e ;
} catch ( ContainerException $e ) {
$this -> handleContainerException ( $e );
throw $e ;
} catch ( Throwable $e ) {
$this -> handleUnexpectedException ( $e );
throw $e ;
}
}
private function handleRunnerException ( RunnerException $e ) : void
{
$this -> logger -> error ( 'Workflow job failed' , [
'job' => $e -> name ,
'attempt' => $e -> attempt ,
'error' => $e -> throwable -> getMessage (),
'file' => $e -> getFile (),
'line' => $e -> getLine (),
]);
$this -> reporter -> report ( $e );
}
private function handleContainerException ( ContainerException $e ) : void
{
$this -> logger -> critical ( 'Dependency injection failed' , [
'error' => $e -> getMessage (),
]);
}
private function handleUnexpectedException ( Throwable $e ) : void
{
$this -> logger -> critical ( 'Unexpected workflow error' , [
'error' => $e -> getMessage (),
'trace' => $e -> getTraceAsString (),
]);
$this -> reporter -> report ( $e );
}
}
Testing error scenarios
Write tests for error handling:
use PHPUnit\Framework\ TestCase ;
class ErrorHandlingTest extends TestCase
{
public function testJobFailureThrowsRunnerException () : void
{
$workflow = workflow (
failing : sync ( new FailingAction ()),
);
$this -> expectException ( RunnerException :: class );
$this -> expectExceptionMessage ( '[failing]: Expected error' );
run ( $workflow );
}
public function testRetryExhaustion () : void
{
$workflow = workflow (
unstable : sync ( new UnstableAction ())
-> withRetry ( maxAttempts : 3 ),
);
try {
run ( $workflow );
$this -> fail ( 'Expected RunnerException' );
} catch ( RunnerException $e ) {
$this -> assertSame ( 'unstable' , $e -> name );
$this -> assertSame ( 3 , $e -> attempt );
}
}
public function testSkippedJobHandling () : void
{
$workflow = workflow (
conditional : async ( new MyAction ())
-> withRunIf ( variable ( 'enabled' )),
);
$run = run ( $workflow , enabled : false );
$this -> assertTrue ( $run -> skip () -> contains ( 'conditional' ));
$this -> expectException ( OutOfBoundsException :: class );
$run -> response ( 'conditional' );
}
}
Best practices
Always catch RunnerException
Wrap workflow execution in try-catch blocks to handle job failures gracefully. try {
$run = run ( $workflow );
} catch ( RunnerException $e ) {
// Handle error
}
Check skip status before accessing responses
Verify a job wasn’t skipped before accessing its response. if ( ! $run -> skip () -> contains ( 'job1' )) {
$response = $run -> response ( 'job1' );
}
Use retry policies for transient failures
Configure retries for operations that may fail temporarily. -> withRetry (
timeout : 5000 ,
maxAttempts : 3 ,
delay : 1000
)
Log errors with context
Include job name, attempt number, and original error in logs. $logger -> error ( 'Job failed' , [
'job' => $e -> name ,
'attempt' => $e -> attempt ,
'error' => $e -> throwable -> getMessage (),
]);
Design for failure
Use conditional execution and fallback strategies to handle errors gracefully.
Common error patterns
$job = async ( new ApiRequest ())
-> withRetry (
timeout : 10000 ,
maxAttempts : 3 ,
delay : 2000
);
try {
$run = run ( workflow ( api : $job ));
} catch ( RunnerException $e ) {
if ( $e -> throwable instanceof NetworkException ) {
// Handle network error
}
}
$workflow = workflow (
validate : async ( new Validate ()),
process : async ( new Process ())
-> withRunIf ( response ( 'validate' , 'isValid' )),
);
$run = run ( $workflow , data : $input );
if ( $run -> skip () -> contains ( 'process' )) {
$errors = $run -> response ( 'validate' , 'errors' ) -> array ();
// Handle validation errors
}
try {
run ( workflow (
job : sync ( ActionWithDeps :: class )
));
} catch ( ContainerException $e ) {
// Register missing dependencies
$container = new Container (
dependency : new Dependency ()
);
run ( $workflow , $container );
}
$job = sync ( new LongRunning ())
-> withRetry (
timeout : 5000 ,
maxAttempts : 1
);
try {
run ( workflow ( long : $job ));
} catch ( RunnerException $e ) {
if ( $e -> throwable instanceof CancelledException ) {
// Job exceeded timeout
}
}