') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); [core] Enforce maxRetries for steps that time out by VaguelySerious · Pull Request #3035 · vercel/workflow · GitHub
Skip to content

[core] Enforce maxRetries for steps that time out - #3035

Merged
VaguelySerious merged 4 commits into
mainfrom
peter/step-maxretries-timeout
Jul 21, 2026
Merged

[core] Enforce maxRetries for steps that time out#3035
VaguelySerious merged 4 commits into
mainfrom
peter/step-maxretries-timeout

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Problem

A step that throws is bounded correctly by maxRetries. A step that times out (the platform hard-kills the function mid-run) is not — it retries unbounded.

Chain:

  1. Every step_started increments attempt (world storage, "increment on every start").
  2. A timeout kills the function while awaiting, so no step_failed/step_retrying is writtenstep.error stays null. The queue then redelivers the step and it runs again.
  3. The pre-body max-retries guard in step-executor.ts is gated on && step.error, so it never fires for timeouts. The post-body guard only runs when the body actually throws — a timeout never reaches it.

Additionally, the optimistic-inline-start path synthesizes step.attempt = 1, so even the world-returned attempt can't be trusted inline.

Minimal repro (retries forever instead of stopping at the default 3):

asyncfunctiontimeoutWorkflow(){"use workflow";awaittimeoutStep();}asyncfunctiontimeoutStep(){"use step";awaitnewPromise((r)=>setTimeout(r,15*60*1000));// killed by function timeout}

Fix

Enforce the retry ceiling before the body runs, via a new authoritativeAttempt param on executeStep. Callers supply a count that reflects real attempts:

  • Inline (combined handler): the number of step_started events already in the event log for the step, plus one for the attempt about to run. The log is authoritative (the optimistic path synthesizes attempt = 1; concurrent double-starts are prevented by the atomic create-claim / single-flight).
  • 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 (a catchable FatalError bubbled to the workflow) without starting another attempt. Thrown-error exhaustion is unchanged: it still terminates one attempt earlier via the post-body guard, with the thrown error as cause.

Validation

  • New unit tests (step-executor.test.ts): the ceiling fails a step without running the body — and without writing a new step_started — once the attempt exceeds maxRetries + 1, and permits the final allowed attempt (maxRetries + 1).
  • Full packages/core runtime suite green (336 tests).
  • A true end-to-end timeout can't be reproduced locally (world-local can't hard-kill a function invocation); the enforcement decision is unit-tested at executeStep, and both call sites are thin param passes.

Notes / open questions

  • For the pure-timeout exhaustion the FatalError has no cause (there is no recorded error to attach). Matches the shape of the existing max-retries error; happy to attach a best-effort prior-error cause if preferred.

🤖 Generated with Claude Code

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>
@changeset-bot

changeset-botBot commented Jul 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 75b39f3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15400
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028
✅ vercel-multi-region
AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 75b39f3 · Tue, 21 Jul 2026 23:17:19 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep129 (+23%) 🔻250 🔴 (-39%) 💚353 🔴 (-37%) 💚441 (-88%) 💚30
TTFSstream108 (-28%) 💚256 🔴 (-45%) 💚333 🔴 (-51%) 💚405 (-86%) 💚30
TTFShook + stream302 (+11%)463 🔴 (-15%)549 🔴 (-31%) 💚888 🔴 (-76%) 💚30
STSO1020 steps (1-20)225 (+25%) 🔻364 🔴 (+11%)429 🔴 (+3.6%)512 🔴 (+23%) 🔻19
STSO1020 steps (101-120)272 (+14%)439 🔴 (+20%) 🔻460 🔴 (+8.7%)1343 🔴 (+198%) 🔻19
STSO1020 steps (1001-1020)692 (+3.7%)839 🔴 (-1.6%)1024 🔴 (+9.8%)1037 🔴 (+3.6%)19
WO1020 steps566144 (+3.6%)566144 (+3.6%)566144 (+3.6%)566144 (+3.6%)1
SLstream latency81 (-9.0%)124 🔴 (-3.1%)147 🔴 (-6.4%)282 🔴 (+26%) 🔻30
📜 Previous results (3)

f7f8745

Tue, 21 Jul 2026 22:05:55 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep148 (+41%) 🔻268 🔴 (-34%) 💚379 🔴 (-32%) 💚439 (-88%) 💚30
TTFSstream146 (-2.0%)203 🔴 (-56%) 💚282 (-59%) 💚429 (-85%) 💚30
TTFShook + stream327 (+20%) 🔻486 🔴 (-10%)571 🔴 (-29%) 💚962 🔴 (-74%) 💚30
STSO1020 steps (1-20)187 (+3.9%)315 🔴 (-4.3%)334 🔴 (-19%) 💚352 🔴 (-15%) 💚19
STSO1020 steps (101-120)216 (-9.6%)338 🔴 (-7.7%)432 🔴 (+2.1%)670 🔴 (+49%) 🔻19
STSO1020 steps (1001-1020)582 (-13%)754 🔴 (-12%)796 🔴 (-15%)925 🔴 (-7.6%)19
WO1020 steps519288 (-5.0%)519288 (-5.0%)519288 (-5.0%)519288 (-5.0%)1
SLstream latency81 (-9.0%)116 🔴 (-9.4%)134 🔴 (-15%)187 🔴 (-17%) 💚30

67b5082

Tue, 21 Jul 2026 20:47:19 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep141 (+27%) 🔻272 🔴 (+22%) 🔻337 🔴 (-17%) 💚411 (-22%) 💚30
TTFSstream129 (-5.1%)341 🔴 (+18%) 🔻408 🔴 (+13%)482 (-40%) 💚30
TTFShook + stream319 (-9.1%)500 🔴 (-1.6%)659 🔴 (+14%)852 🔴 (+38%) 🔻30
STSO1020 steps (1-20)187 (+9.4%)290 🔴 (+9.4%)292 🔴 (-9.3%)300 🔴 (-18%) 💚19
STSO1020 steps (101-120)265 (+29%) 🔻382 🔴 (+25%) 🔻467 🔴 (+37%) 🔻613 🔴 (+30%) 🔻19
STSO1020 steps (1001-1020)631 (+7.7%)788 🔴 (+10%)896 🔴 (+15%) 🔻1301 🔴 (+59%) 🔻19
WO1020 steps554332 (+5.1%)554332 (+5.1%)554332 (+5.1%)554332 (+5.1%)1
SLstream latency96 (+26%) 🔻187 🔴 (+80%) 🔻264 🔴 (+59%) 🔻365 🔴 (+51%) 🔻30

46b0233

Tue, 21 Jul 2026 19:30:48 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep139 (+25%) 🔻286 🔴 (+28%) 🔻313 🔴 (-23%) 💚2578 🔴 (+390%) 🔻30
TTFSstream138 (+1.5%)268 🔴 (-7.3%)351 🔴 (-3.0%)730 🔴 (-9.1%)30
TTFShook + stream269 (-23%) 💚406 🔴 (-20%) 💚442 🔴 (-23%) 💚611 🔴 (-1.0%)30
STSO1020 steps (1-20)187 (+9.4%)287 🔴 (+8.3%)316 🔴 (-1.9%)374 🔴 (+2.7%)19
STSO1020 steps (101-120)242 (+17%) 🔻365 🔴 (+20%) 🔻381 🔴 (+11%)446 🔴 (-5.5%)19
STSO1020 steps (1001-1020)549 (-6.3%)667 🔴 (-6.6%)739 🔴 (-4.9%)788 🔴 (-3.9%)19
WO1020 steps518699 (-1.6%)518699 (-1.6%)518699 (-1.6%)518699 (-1.6%)1
SLstream latency90 (+18%) 🔻163 🔴 (+57%) 🔻215 🔴 (+30%) 🔻431 🔴 (+78%) 🔻30

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt)

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

Comment threadpackages/core/src/runtime.ts Outdated
Comment threadpackages/core/src/runtime.ts Outdated
Comment threadpackages/core/src/runtime.ts Outdated
Comment threadpackages/core/src/runtime.ts
Comment threadpackages/core/src/runtime/step-executor.ts Outdated
Comment threadpackages/core/src/runtime/step-executor.ts
Comment thread.changeset/step-maxretries-timeout.md Outdated
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after the background retry-count fix. The correctness blocker is resolved; remaining performance concern is non-blocking.

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>
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 9177ba8 (AI decision).

This fix targets the main-only step execution architecture: packages/core/src/runtime/step-executor.ts does not exist on stable (verified via git ls-tree), and the modified runtime.ts paths (optimistic inline starts, lazy steps, authoritativeAttempt plumbing) are part of the combined-handler refactor absent from stable. Moreover, the underlying bug appears not to exist on stable: its step-handler.ts already enforces step.attempt > maxRetries + 1 after step_started without the step.error gate that caused unbounded retries on main, so timed-out steps are already bounded there.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

9177ba83d3168866d13ff34ca3d651312d1d87d2

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@VaguelySerious@karthikscale3