Failures & retries
Durable execution does not make every error retryable. Classify transport delivery, activity attempts, deterministic replay, and terminal workflow outcomes separately.
Know which layer failed
| Failure | Meaning | Usual response |
|---|---|---|
TransportException |
The SDK could not complete an HTTP exchange. | Retry only when request identity makes replay safe. |
ServerException |
The runtime rejected or failed an operation. | Inspect status, reason, and details. |
ActivityFailed |
A recorded activity reached a failed terminal attempt. | Catch in workflow code only when compensation is deterministic. |
NonDeterministicWorkflow |
Code no longer matches committed history. | Restore compatible code; do not blindly retry. |
WorkflowFailed |
The execution closed as failed. | Surface the recorded failure to the caller. |
WorkflowTimedOut |
A Server deadline closed the run. | Start a new workflow only if business policy permits. |
WorkflowCancelled / WorkflowTerminated |
Both close the Server run immediately, with distinct terminal reasons. | Do not expect workflow-code cleanup after either command. |
Make activity retries safe
Pass activity policy in the command options supported by your runtime, and make the downstream action idempotent:
$receipt = $context->activity(
'payments.capture',
[$orderId, $amount],
[
'start_to_close_timeout_seconds' => 30,
'retry_policy' => [
'maximum_attempts' => 5,
'initial_interval_seconds' => 1,
'maximum_interval_seconds' => 20,
],
],
);
Use the order or activity identity as the payment provider's idempotency key. A retry policy cannot repair a non-idempotent downstream API.
Durable cleanup
Workflow finally blocks can await cleanup activities, timers, and metadata
writes after success or a handled failure. Replay reconstructs those commands
from history. Discarding a suspended replay Fiber does not schedule cleanup or
complete the workflow; PHP may still unwind local finally code, so put external
effects in activities rather than directly in the workflow body.
Do not use finally as a guarantee against terminal cancelWorkflow(),
terminateWorkflow(), process death, or an already closed run. Service-mode
Server does not yet expose the separate cooperative cancellation request
available to embedded Laravel. When business cancellation requires
compensation to finish, signal that intent and let the workflow complete its
cleanup before closing the run.
Heartbeat long attempts
foreach ($batches as $index => $batch) {
importBatch($batch);
$context->heartbeat(['completed_batches' => $index + 1]);
}
Heartbeat details make slow work observable and provide a cancellation checkpoint. Catch ActivityCancelled only to release local resources, then rethrow it so the runtime records cancellation correctly.
Preserve terminal distinctions at the client
try {
$result = $handle->result(timeoutSeconds: 60);
} catch (WorkflowTimedOut $failure) {
reportDeadline($failure);
} catch (WorkflowFailed $failure) {
reportBusinessFailure($failure);
}
A client-side wait timeout is not proof that the workflow failed; describe the handle or wait again. A workflow terminal timeout is durable Server state.
Treat nondeterminism as a deployment incident
Do not paper over NonDeterministicWorkflow with process retries. Compare the deployed build with the history's worker routing, restore the old command sequence, and roll forward with a replay-compatible change. Keep a replay corpus for long-lived workflow histories in testing.
Before adding a retry, identify the durable request ID, the side effect's idempotency boundary, and what evidence proves the prior attempt did not already succeed.