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. |
WorkflowEntryArgumentsMismatch |
Recorded start input cannot bind to the entry method, before its body runs. The workflow task is refused and the run remains recoverable. | Restore a compatible entry method, inspect the task and use run-scoped repair. |
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 |
The run closed as Cancelled, after terminal cancellation or successful cooperative cleanup. | Inspect the cancellation lifecycle and cleanup outcome. |
WorkflowTerminated |
Termination closed the run immediately. | Do not expect workflow-code cleanup after termination. |
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' => 30,
'retry_policy' => [
'max_attempts' => 5,
'backoff_seconds' => [1, 2, 4, 8],
],
],
);
max_attempts includes the first attempt. This example permits four retries,
with the listed delays in seconds. When there are more retries than delays, the
last delay repeats: max_attempts: 5 with backoff_seconds: [1, 2] waits 1, 2,
2 and 2 seconds. Local activities follow the same rule from SDK 3.13.0. Unsupported option or retry-policy field
names throw InvalidArgumentException before an activity command is submitted.
Use the order or activity identity as the payment provider's idempotency key. A retry policy cannot repair a non-idempotent downstream API.
Older SDKs forwarded unsupported names and the runtime applied its defaults. Correcting those names changes the durable activity policy. For existing runs, keep the previously recorded policy in a replay-compatible version branch, or finish those runs before deploying the changed policy.
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.
Use requestCancellation() with cooperating workers when cancellation requires
bounded workflow cleanup. The request preserves one original deadline across
children, activities, duplicates and worker replacement. Shielded durable
cleanup replays after worker loss and successful cleanup closes the run as
Cancelled. See the cooperative cancellation guide
for worker opt-in, operation policies and inspection.
Terminal cancelWorkflow() and terminateWorkflow() still close the run
immediately. They do not resume workflow cleanup, and an already closed run
cannot schedule compensation through finally.
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.
Cooperative workers supervise callbacks independently of application heartbeats. A stop receipt proves callback stop, while attempt fencing rejects a stale result. Downstream side effects still need application idempotency or reconciliation.
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.
Bound failure diagnostics
Failure reporting keeps diagnostic content within finite defaults:
| Content | Default budget |
|---|---|
| UTF-8 message, including its marker | 62 KiB |
| Stack trace, including diagnostic markers | 128 KiB |
| Serialized details and diagnostics together | 64 KiB |
| Complete encoded failure request, including JSON escaping and identity | 1 MiB |
The message budget reserves 2 KiB below the original 64 KiB ceiling for published runtime summaries. It does not repair the separate runtime summary byte-bound defect tracked in shared issue 225.
Oversized content produces a visible [truncated failure report; ...] message
marker and bounded diagnostics with original sizes and omitted fields. The SDK
retains a complete valid Avro details envelope or explicitly omits it. It never
slices Avro or base64 bytes. Small reports keep their message and details.
The original failure type, retry decision, authored retry delays and claimed attempt identity remain unchanged. Diagnostic limits are separate from business payload and worker request timeout budgets. Cooperative callbacks also bound failure diagnostics before sending them to their owning worker.
For a transport with a smaller request budget, narrow the client limit:
$client = $client->withFailureReportLimit(128 * 1024);
An injected transport can expose a lower known body budget through
RequestSizeLimitTransport. The lower limit applies after markers and JSON
escaping are included. If required identity or fixed request content cannot fit,
FailureReportRefused reports failure_report_refused locally. Treat this as a
reporting refusal requiring configuration or identity repair, rather than a new
application failure or permission to publish under a different attempt.
Successful local records keep their normal business-payload budget. When a completion includes both failure diagnostics and a large business result, the existing runtime upload protocol preserves the complete Avro payload while keeping the final failure request bounded. If fixed content cannot fit and the namespace cannot store that payload, reporting refuses explicitly. No partial business payload is published.
Local callback failures normalize before workflow code branches on them. The first execution, persisted local record and fresh replay use the same bounded message, type and retry decision.
Activity failures use existing diagnostic fields. Workflow-task failures use the existing stack string for structured truncation information. The published Server qualification records task failures and grants a legitimate retry turn, but that runtime does not append a workflow-task failure event to workflow history or persist its stack diagnostics. That visibility work remains tracked in shared issue 225.