Skip to main content
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:
tests/RunnerTest.php
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

1

Always catch RunnerException

Wrap workflow execution in try-catch blocks to handle job failures gracefully.
try {
    $run = run($workflow);
} catch (RunnerException $e) {
    // Handle error
}
2

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');
}
3

Use retry policies for transient failures

Configure retries for operations that may fail temporarily.
->withRetry(
    timeout: 5000,
    maxAttempts: 3,
    delay: 1000
)
4

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(),
]);
5

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
    }
}

Build docs developers (and LLMs) love