Failures and retries#

An activity that fails is not one event but several, and telling them apart is what lets you decide whether to compensate, alert, or let the workflow die.


What the journal records for one activity#

Event Meaning
ActivityScheduled the workflow asked for it
ActivityTaskStarted one attempt began — one per attempt
ActivityTaskFailed one attempt failed, whether or not another follows
ActivityTaskCompleted one attempt succeeded
ActivityCompleted final outcome: success
ActivityFailed final outcome: failure
ActivityCancelled final outcome: removed before completing
ActivityCatastrophicFailure the failure itself could not be safely journaled

ActivityTaskFailed matters: without it, an attempt that failed and was followed by a success left no trace at all. The first error simply vanished from the journal.


Why an activity stopped retrying#

ActivityFailed carries a retryState telling you which of four situations you are in — they used to be indistinguishable:

use Gplanchat\Durable\Failure\ActivityRetryState;

$failed->retryState();     // ActivityRetryState
$failed->isStalled();      // true when attempts were exhausted
State Meaning
NonRetryableFailure the exception is declared non-retryable — it will never be retried
MaximumAttemptsReached every allowed attempt was consumed
Timeout a schedule-to-start or schedule-to-close bound elapsed
RetryPolicyNotSet no retry policy applied
InProgress not final — another attempt is expected

InProgress is how a failure that is not an outcome is recorded. It appears when retry is delegated to the Temporal server, and it deliberately does not count as a terminal outcome, so the next attempt really runs.

The state mirrors Temporal’s RetryState: there, too, this is a field on the failure, not a distinct event type.


Declaring an exception non-retryable#

use Gplanchat\Durable\Activity\{ActivityOptions, RetryLimit};

new ActivityOptions(
    RetryLimit::ofAttempts(5),
    nonRetryableExceptions: [PaymentRefusedException::class],
);

A refused card will not get better on the third attempt. On the Temporal backend this becomes the retry policy’s nonRetryableErrorTypes, so the server stops retrying too — not just the PHP worker.


Attempt counting#

RetryLimit::ofAttempts(3) means three executions in total, as on Temporal — not three retries after a first try.

With no explicit limit, attempts are unlimited. An activity that always fails will retry forever and the workflow will never fail. See Options.


When the workflow itself fails#

An error the workflow does not handle produces WorkflowExecutionFailed, whose kind says where it came from:

Kind Origin
unhandled_activity_failure an activity failure the workflow let escape
unhandled_declared_activity_failure a declared business failure it let escape
unhandled_catastrophic_activity_failure an activity failure that could not be journaled
unhandled_activity_superseded a race loser it awaited anyway
workflow_handler_failure the workflow code itself threw
terminated_by_parent a parent closed with ParentClosePolicy::Terminate

On the Temporal backend this kind travels in the ApplicationFailureInfo details, so reading the history back reconstructs a typed WorkflowExecutionFailed rather than a bare message. The failing activity’s name survives the round trip.