Files
vercel__workflow/.changeset/step-maxretries-timeout.md
Peter Wielander 9177ba83d3 [core] Enforce maxRetries for steps that time out (#3035)
* [core] Enforce maxRetries for steps that time out

A step that is hard-killed by the platform function timeout writes no
step_failed/step_retrying, so `step.error` stays null and the error-based
max-retries guards never fire. Each redelivery re-runs step_started
(incrementing the attempt), so a timing-out step retried without bound
instead of stopping at maxRetries.

Enforce the retry ceiling BEFORE running the body, via a new
`authoritativeAttempt` param on executeStep:

- Inline (combined handler): count the step_started events already in the
  event log for the step (+1 for this attempt). The log is authoritative
  because the optimistic-start path synthesizes step.attempt = 1.
- Background (queue-dispatched): the queue delivery count (metadata.attempt),
  which increments on the visibility-timeout redelivery a timed-out step
  produces.

When the attempt exceeds maxRetries + 1 the step is failed without starting
another attempt. Thrown-error exhaustion is unchanged — it still terminates
via the post-body guard one attempt earlier, with the thrown error as cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Apply suggestions from code review

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>

* [core] Verify step_started count before failing a backgrounded step

`metadata.attempt` (queue delivery count) also advances for redeliveries
that never run the step body (ThrottleError, TooEarlyError, other pre-body
failures), so trusting it directly could fail a step as "exceeded max
retries" before the body ever ran.

Use the delivery count only as a fast gate: while it is at or under the
ceiling the step can't be exhausted, so proceed without touching the log.
Only once it crosses the ceiling, load the full event log and derive the
authoritative attempt from the recorded step_started count (which only real
attempts write) — excluding throttle/too-early redeliveries. The load also
primes the replay's cachedEvents/eventsCursor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* [core] Skip the event-log scan for brand-new inline steps

Deriving an inline step's attempt number by scanning the cumulative event
log for step_started events ran for every inline execution, which is O(n²)
across a long sequential workflow.

A lazy inline step is brand-new by construction (it only enters the batch
with no step_created yet), so it has zero prior starts and is always attempt
1 — no scan needed. Reserve the scan for owned-recovery re-runs (this
message re-executing a step it crashed/timed out on), which are uncommon and
few per batch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:31:05 -07:00

102 B

@workflow/core
@workflow/core
patch

Enforce maxRetries for inline and backgrounded steps that time out