[world-vercel] Idempotent retry policy for stream close (5xx retriable) - #3038

Merged
karthikscale3 merged 3 commits into
mainfrom
kk/stream-close-retry
Jul 21, 2026
Merged

[world-vercel] Idempotent retry policy for stream close (5xx retriable)#3038
karthikscale3 merged 3 commits into
mainfrom
kk/stream-close-retry

Conversation

@karthikscale3

Copy link
Copy Markdown
Contributor

Stream close is the one idempotent stream PUT, and its retry policy now reflects that.

Why

Chunk appends deliberately never retry 5xx: a 5xx can mean the chunk was written but the response failed, and a retry would duplicate it (STREAM_RETRY_OPTIONS, unchanged here). Close is different:

  • A duplicate close of a completed stream is a server-side no-op, so re-sending it is always safe.
  • The server may surface transient close-time reconciliation states (e.g., storage backups still landing when the close is processed) as retriable 503s that expect the writer to close again — under the write dispatcher's no-5xx policy, that 503 rejected writer.close() outright, turning a self-healing timing condition into a user-visible stream error.

What changed

  • New STREAM_CLOSE_RETRY_OPTIONS and a dedicated shared RetryAgent for the close PUT: retries 429 + 500/502/503/504 plus transient connection errors, honoring Retry-After.
  • streamer.tsclose() switched to the new dispatcher. Chunk writes (write/writeMulti) keep the narrowed no-5xx policy unchanged.
  • Contract pinned by tests: 5xx present in the close retry set, absent from the write retry set, distinct shared dispatchers, caller-supplied dispatcher still respected.

Notes for reviewers

  • Close-as-fence semantics: once a close has been issued, a concurrent slow write to the same stream may be refused by the server rather than silently included — the changelog entry for the next release should carry a line to that effect.
  • All 299 world-vercel tests green; the change is confined to http-client.ts / streamer.ts plus tests and a changeset.

🤖 Generated with Claude Code

karthikscale3and others added 2 commits July 21, 2026 14:27
Stream close is the one idempotent stream PUT: a duplicate close of a
completed stream early-returns on the server, and the close-barrier
protocol's durable `closing` fence is an if_not_exists stamp that a
re-entered close resumes. The barrier protocol relies on close retrying
5xx: transient reconciliation failures — and unsafe close shapes
awaiting in-flight backups — surface as retriable 503s with the stream
left durably closing, expecting the writer to close again. Under the
write dispatcher's no-5xx policy (correct for non-idempotent chunk
appends), that 503 rejected writer.close() outright and left the stream
fenced until run expiry.
Close now uses its own shared RetryAgent (429 + 5xx + transient
connection errors, Retry-After honored); chunk writes keep the narrowed
no-5xx policy unchanged. Contract pinned by tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 21, 2026 21:28
@vercel

vercelBot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e9e1dee

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

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

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

@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 e9e1dee · Tue, 21 Jul 2026 21:45:42 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep156 (+41%) 🔻384 🔴 (+72%) 🔻679 🔴 (+67%) 🔻3201 🔴 (+509%) 🔻30
TTFSstream151 (+11%)412 🔴 (+43%) 🔻2524 🔴 (+597%) 🔻3641 🔴 (+353%) 🔻30
TTFShook + stream290 (-17%) 💚522 🔴 (+2.8%)647 🔴 (+12%)3042 🔴 (+393%) 🔻30
STSO1020 steps (1-20)226 (+32%) 🔻363 🔴 (+37%) 🔻467 🔴 (+45%) 🔻675 🔴 (+85%) 🔻19
STSO1020 steps (101-120)287 (+39%) 🔻426 🔴 (+40%) 🔻566 🔴 (+65%) 🔻632 🔴 (+34%) 🔻19
STSO1020 steps (1001-1020)446 (-24%) 💚542 🔴 (-24%) 💚629 🔴 (-19%) 💚744 🔴 (-9.3%)19
WO1020 steps551510 (+4.6%)551510 (+4.6%)551510 (+4.6%)551510 (+4.6%)1
SLstream latency92 (+21%) 🔻174 🔴 (+67%) 🔻194 🔴 (+17%) 🔻390 🔴 (+61%) 🔻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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 merged commit 59c1369 into mainJul 21, 2026
173 of 177 checks passed
@karthikscale3
karthikscale3 deleted the kk/stream-close-retry branch July 21, 2026 23:01
@github-actionsgithub-actionsBot mentioned this pull request Jul 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 59c1369 (AI decision).

This fix builds on the stream-specific dispatcher infrastructure (STREAM_RETRY_OPTIONS, getStreamDispatcher) that exists only on main — verified that stable's packages/world-vercel/src/http-client.ts has a single shared RetryAgent with none of these exports. On stable, that shared dispatcher already retries 5xx (500/502/503/504) for idempotent methods like the close PUT, so the bug being fixed (close rejected on retriable 503) does not exist there; it was a regression introduced by the main-only no-5xx stream write dispatcher.

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

59c13697c9919a4a96c012182e1adc0c58114ba5

pranaygp added a commit that referenced this pull request Jul 22, 2026
* origin/main: (162 commits)
Implement `max_events` per run limit (#2986)
[core] Enforce maxRetries for steps that time out (#3035)
[world-vercel] Idempotent retry policy for stream close (5xx retriable) (#3038)
[world] Guard hook_received against a concurrent run termination (#2987)
docs: fix stale/incorrect v5 API reference details (#3017)
Default WORKFLOW_PRECONDITION_GUARD on (#2946)
docs: replace migration guides with a Comparisons section (#2676)
feat(core): add experimental Hook minimum retention (#2865)
test: regression coverage for hook.resume() from isolated route bundles (o2flow beta.26 incident) (#3001)
docs(agents): note lint/format/typecheck are advisory, not blocking (#2886)
Retry transient connection timeouts (#3013)
fix(world-vercel): append caller User-Agent products instead of discarding them (#2998)
[ci] Enable NestJS e2e-vercel-prod and add to docs as "experimental" (#3011)
[ci] Benchmark comment: Best column + best/p75/p99 deltas (drop Avg/P10) (#3005)
docs: fall back to first child page for sidebar folders without an index (#3009)
[nest] Fix NestJS Vercel build output (#2988)
Avoid resolving run data for background steps (#2993)
chore(docs): update @vercel/geistdocs to 1.14.0 (#3002)
fix(docs): add version-switcher fallback redirects for pages missing in one version (#3003)
ci: update opencode to 1.18.4 and switch backport AI model to claude-fable-5 (#3006)
...
# Conflicts:
#	docs/content/docs/v4/deploying/meta.json
#	docs/content/docs/v5/deploying/meta.json
#	packages/core/src/runtime.ts
#	packages/world-local/src/index.ts
#	packages/world-postgres/src/index.ts
#	packages/world/src/events.ts
#	packages/world/src/interfaces.ts
#	packages/world/src/recovery.ts
#	workbench/nest/src/main.ts
#	workbench/sveltekit/src/hooks.server.ts
VaguelySerious added a commit that referenced this pull request Jul 31, 2026
Backport of #2631, adapted to stable. The v3 makeRequest path, the v4
events client, the stream write/close path and the direct Vercel API
calls each had their own request envelope; the 412 -> PreconditionFailed
and firewall-challenge-429 -> retryable TRANSPORT mappings were
duplicated between utils.ts and events-v4.ts.
http-core.ts now owns that envelope: the OTEL CLIENT span and its
attributes, W3C trace-context injection, the X-Request-Time cache bust,
the per-request deadline (mapped to a typed retryable WorkflowWorldError
instead of an opaque AbortError), DEBUG logging with a curl repro, and
the status -> typed-error mapping. utils.ts, events-v4.ts, streamer.ts,
encryption.ts and resolve-latest-deployment.ts delegate to it.
Behavioral gains on stable: the v4 events path and stream writes now
emit client spans and propagate traceparent, so the backend can parent
its spans to the caller without relying on the app having undici
auto-instrumentation.
Adapted rather than cherry-picked: stable keeps #3169's bounded
headers/body timeouts (documented as firing ahead of the outer
REQUEST_TIMEOUT_MS backstop), its body-parse retry loop, and #3038's
write-vs-close stream dispatcher split. Only injectTraceContextIntoHeaders
is taken from #2363, not its WORKFLOW_TRACE_MODE machinery.
Routing v4 through the global fetch makes the H2 interceptor's
re-buffering gate live on this path (fetch streamifies request bodies),
so the new http-client test drives createWorkflowRunEventV4 concurrently
against a local H2 server: 16 writes on one session with the
interceptor, 8 without it.
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

@karthikscale3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

[world-vercel] Idempotent retry policy for stream close (5xx retriable) - #3038

Merged
karthikscale3 merged 3 commits into
mainfrom
kk/stream-close-retry
Jul 21, 2026
Merged

[world-vercel] Idempotent retry policy for stream close (5xx retriable)#3038
karthikscale3 merged 3 commits into
mainfrom
kk/stream-close-retry

Conversation

@karthikscale3

Copy link
Copy Markdown
Contributor

Stream close is the one idempotent stream PUT, and its retry policy now reflects that.

Why

Chunk appends deliberately never retry 5xx: a 5xx can mean the chunk was written but the response failed, and a retry would duplicate it (STREAM_RETRY_OPTIONS, unchanged here). Close is different:

  • A duplicate close of a completed stream is a server-side no-op, so re-sending it is always safe.
  • The server may surface transient close-time reconciliation states (e.g., storage backups still landing when the close is processed) as retriable 503s that expect the writer to close again — under the write dispatcher's no-5xx policy, that 503 rejected writer.close() outright, turning a self-healing timing condition into a user-visible stream error.

What changed

  • New STREAM_CLOSE_RETRY_OPTIONS and a dedicated shared RetryAgent for the close PUT: retries 429 + 500/502/503/504 plus transient connection errors, honoring Retry-After.
  • streamer.tsclose() switched to the new dispatcher. Chunk writes (write/writeMulti) keep the narrowed no-5xx policy unchanged.
  • Contract pinned by tests: 5xx present in the close retry set, absent from the write retry set, distinct shared dispatchers, caller-supplied dispatcher still respected.

Notes for reviewers

  • Close-as-fence semantics: once a close has been issued, a concurrent slow write to the same stream may be refused by the server rather than silently included — the changelog entry for the next release should carry a line to that effect.
  • All 299 world-vercel tests green; the change is confined to http-client.ts / streamer.ts plus tests and a changeset.

🤖 Generated with Claude Code

karthikscale3and others added 2 commits July 21, 2026 14:27
Stream close is the one idempotent stream PUT: a duplicate close of a
completed stream early-returns on the server, and the close-barrier
protocol's durable `closing` fence is an if_not_exists stamp that a
re-entered close resumes. The barrier protocol relies on close retrying
5xx: transient reconciliation failures — and unsafe close shapes
awaiting in-flight backups — surface as retriable 503s with the stream
left durably closing, expecting the writer to close again. Under the
write dispatcher's no-5xx policy (correct for non-idempotent chunk
appends), that 503 rejected writer.close() outright and left the stream
fenced until run expiry.
Close now uses its own shared RetryAgent (429 + 5xx + transient
connection errors, Retry-After honored); chunk writes keep the narrowed
no-5xx policy unchanged. Contract pinned by tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 21, 2026 21:28
@vercel

vercelBot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e9e1dee

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

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

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

@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 e9e1dee · Tue, 21 Jul 2026 21:45:42 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep156 (+41%) 🔻384 🔴 (+72%) 🔻679 🔴 (+67%) 🔻3201 🔴 (+509%) 🔻30
TTFSstream151 (+11%)412 🔴 (+43%) 🔻2524 🔴 (+597%) 🔻3641 🔴 (+353%) 🔻30
TTFShook + stream290 (-17%) 💚522 🔴 (+2.8%)647 🔴 (+12%)3042 🔴 (+393%) 🔻30
STSO1020 steps (1-20)226 (+32%) 🔻363 🔴 (+37%) 🔻467 🔴 (+45%) 🔻675 🔴 (+85%) 🔻19
STSO1020 steps (101-120)287 (+39%) 🔻426 🔴 (+40%) 🔻566 🔴 (+65%) 🔻632 🔴 (+34%) 🔻19
STSO1020 steps (1001-1020)446 (-24%) 💚542 🔴 (-24%) 💚629 🔴 (-19%) 💚744 🔴 (-9.3%)19
WO1020 steps551510 (+4.6%)551510 (+4.6%)551510 (+4.6%)551510 (+4.6%)1
SLstream latency92 (+21%) 🔻174 🔴 (+67%) 🔻194 🔴 (+17%) 🔻390 🔴 (+61%) 🔻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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 merged commit 59c1369 into mainJul 21, 2026
173 of 177 checks passed
@karthikscale3
karthikscale3 deleted the kk/stream-close-retry branch July 21, 2026 23:01
@github-actionsgithub-actionsBot mentioned this pull request Jul 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 59c1369 (AI decision).

This fix builds on the stream-specific dispatcher infrastructure (STREAM_RETRY_OPTIONS, getStreamDispatcher) that exists only on main — verified that stable's packages/world-vercel/src/http-client.ts has a single shared RetryAgent with none of these exports. On stable, that shared dispatcher already retries 5xx (500/502/503/504) for idempotent methods like the close PUT, so the bug being fixed (close rejected on retriable 503) does not exist there; it was a regression introduced by the main-only no-5xx stream write dispatcher.

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

59c13697c9919a4a96c012182e1adc0c58114ba5

pranaygp added a commit that referenced this pull request Jul 22, 2026
* origin/main: (162 commits)
Implement `max_events` per run limit (#2986)
[core] Enforce maxRetries for steps that time out (#3035)
[world-vercel] Idempotent retry policy for stream close (5xx retriable) (#3038)
[world] Guard hook_received against a concurrent run termination (#2987)
docs: fix stale/incorrect v5 API reference details (#3017)
Default WORKFLOW_PRECONDITION_GUARD on (#2946)
docs: replace migration guides with a Comparisons section (#2676)
feat(core): add experimental Hook minimum retention (#2865)
test: regression coverage for hook.resume() from isolated route bundles (o2flow beta.26 incident) (#3001)
docs(agents): note lint/format/typecheck are advisory, not blocking (#2886)
Retry transient connection timeouts (#3013)
fix(world-vercel): append caller User-Agent products instead of discarding them (#2998)
[ci] Enable NestJS e2e-vercel-prod and add to docs as "experimental" (#3011)
[ci] Benchmark comment: Best column + best/p75/p99 deltas (drop Avg/P10) (#3005)
docs: fall back to first child page for sidebar folders without an index (#3009)
[nest] Fix NestJS Vercel build output (#2988)
Avoid resolving run data for background steps (#2993)
chore(docs): update @vercel/geistdocs to 1.14.0 (#3002)
fix(docs): add version-switcher fallback redirects for pages missing in one version (#3003)
ci: update opencode to 1.18.4 and switch backport AI model to claude-fable-5 (#3006)
...
# Conflicts:
#	docs/content/docs/v4/deploying/meta.json
#	docs/content/docs/v5/deploying/meta.json
#	packages/core/src/runtime.ts
#	packages/world-local/src/index.ts
#	packages/world-postgres/src/index.ts
#	packages/world/src/events.ts
#	packages/world/src/interfaces.ts
#	packages/world/src/recovery.ts
#	workbench/nest/src/main.ts
#	workbench/sveltekit/src/hooks.server.ts
VaguelySerious added a commit that referenced this pull request Jul 31, 2026
Backport of #2631, adapted to stable. The v3 makeRequest path, the v4
events client, the stream write/close path and the direct Vercel API
calls each had their own request envelope; the 412 -> PreconditionFailed
and firewall-challenge-429 -> retryable TRANSPORT mappings were
duplicated between utils.ts and events-v4.ts.
http-core.ts now owns that envelope: the OTEL CLIENT span and its
attributes, W3C trace-context injection, the X-Request-Time cache bust,
the per-request deadline (mapped to a typed retryable WorkflowWorldError
instead of an opaque AbortError), DEBUG logging with a curl repro, and
the status -> typed-error mapping. utils.ts, events-v4.ts, streamer.ts,
encryption.ts and resolve-latest-deployment.ts delegate to it.
Behavioral gains on stable: the v4 events path and stream writes now
emit client spans and propagate traceparent, so the backend can parent
its spans to the caller without relying on the app having undici
auto-instrumentation.
Adapted rather than cherry-picked: stable keeps #3169's bounded
headers/body timeouts (documented as firing ahead of the outer
REQUEST_TIMEOUT_MS backstop), its body-parse retry loop, and #3038's
write-vs-close stream dispatcher split. Only injectTraceContextIntoHeaders
is taken from #2363, not its WORKFLOW_TRACE_MODE machinery.
Routing v4 through the global fetch makes the H2 interceptor's
re-buffering gate live on this path (fetch streamifies request bodies),
so the new http-client test drives createWorkflowRunEventV4 concurrently
against a local H2 server: 16 writes on one session with the
interceptor, 8 without it.
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

@karthikscale3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[world-vercel] Idempotent retry policy for stream close (5xx retriable) - #3038

Merged
karthikscale3 merged 3 commits into
mainfrom
kk/stream-close-retry
Jul 21, 2026
Merged

[world-vercel] Idempotent retry policy for stream close (5xx retriable)#3038
karthikscale3 merged 3 commits into
mainfrom
kk/stream-close-retry

Conversation

@karthikscale3

Copy link
Copy Markdown
Contributor

Stream close is the one idempotent stream PUT, and its retry policy now reflects that.

Why

Chunk appends deliberately never retry 5xx: a 5xx can mean the chunk was written but the response failed, and a retry would duplicate it (STREAM_RETRY_OPTIONS, unchanged here). Close is different:

  • A duplicate close of a completed stream is a server-side no-op, so re-sending it is always safe.
  • The server may surface transient close-time reconciliation states (e.g., storage backups still landing when the close is processed) as retriable 503s that expect the writer to close again — under the write dispatcher's no-5xx policy, that 503 rejected writer.close() outright, turning a self-healing timing condition into a user-visible stream error.

What changed

  • New STREAM_CLOSE_RETRY_OPTIONS and a dedicated shared RetryAgent for the close PUT: retries 429 + 500/502/503/504 plus transient connection errors, honoring Retry-After.
  • streamer.tsclose() switched to the new dispatcher. Chunk writes (write/writeMulti) keep the narrowed no-5xx policy unchanged.
  • Contract pinned by tests: 5xx present in the close retry set, absent from the write retry set, distinct shared dispatchers, caller-supplied dispatcher still respected.

Notes for reviewers

  • Close-as-fence semantics: once a close has been issued, a concurrent slow write to the same stream may be refused by the server rather than silently included — the changelog entry for the next release should carry a line to that effect.
  • All 299 world-vercel tests green; the change is confined to http-client.ts / streamer.ts plus tests and a changeset.

🤖 Generated with Claude Code

karthikscale3and others added 2 commits July 21, 2026 14:27
Stream close is the one idempotent stream PUT: a duplicate close of a
completed stream early-returns on the server, and the close-barrier
protocol's durable `closing` fence is an if_not_exists stamp that a
re-entered close resumes. The barrier protocol relies on close retrying
5xx: transient reconciliation failures — and unsafe close shapes
awaiting in-flight backups — surface as retriable 503s with the stream
left durably closing, expecting the writer to close again. Under the
write dispatcher's no-5xx policy (correct for non-idempotent chunk
appends), that 503 rejected writer.close() outright and left the stream
fenced until run expiry.
Close now uses its own shared RetryAgent (429 + 5xx + transient
connection errors, Retry-After honored); chunk writes keep the narrowed
no-5xx policy unchanged. Contract pinned by tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 21, 2026 21:28
@vercel

vercelBot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e9e1dee

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

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

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

@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 e9e1dee · Tue, 21 Jul 2026 21:45:42 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep156 (+41%) 🔻384 🔴 (+72%) 🔻679 🔴 (+67%) 🔻3201 🔴 (+509%) 🔻30
TTFSstream151 (+11%)412 🔴 (+43%) 🔻2524 🔴 (+597%) 🔻3641 🔴 (+353%) 🔻30
TTFShook + stream290 (-17%) 💚522 🔴 (+2.8%)647 🔴 (+12%)3042 🔴 (+393%) 🔻30
STSO1020 steps (1-20)226 (+32%) 🔻363 🔴 (+37%) 🔻467 🔴 (+45%) 🔻675 🔴 (+85%) 🔻19
STSO1020 steps (101-120)287 (+39%) 🔻426 🔴 (+40%) 🔻566 🔴 (+65%) 🔻632 🔴 (+34%) 🔻19
STSO1020 steps (1001-1020)446 (-24%) 💚542 🔴 (-24%) 💚629 🔴 (-19%) 💚744 🔴 (-9.3%)19
WO1020 steps551510 (+4.6%)551510 (+4.6%)551510 (+4.6%)551510 (+4.6%)1
SLstream latency92 (+21%) 🔻174 🔴 (+67%) 🔻194 🔴 (+17%) 🔻390 🔴 (+61%) 🔻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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 merged commit 59c1369 into mainJul 21, 2026
173 of 177 checks passed
@karthikscale3
karthikscale3 deleted the kk/stream-close-retry branch July 21, 2026 23:01
@github-actionsgithub-actionsBot mentioned this pull request Jul 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 59c1369 (AI decision).

This fix builds on the stream-specific dispatcher infrastructure (STREAM_RETRY_OPTIONS, getStreamDispatcher) that exists only on main — verified that stable's packages/world-vercel/src/http-client.ts has a single shared RetryAgent with none of these exports. On stable, that shared dispatcher already retries 5xx (500/502/503/504) for idempotent methods like the close PUT, so the bug being fixed (close rejected on retriable 503) does not exist there; it was a regression introduced by the main-only no-5xx stream write dispatcher.

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

59c13697c9919a4a96c012182e1adc0c58114ba5

pranaygp added a commit that referenced this pull request Jul 22, 2026
* origin/main: (162 commits)
Implement `max_events` per run limit (#2986)
[core] Enforce maxRetries for steps that time out (#3035)
[world-vercel] Idempotent retry policy for stream close (5xx retriable) (#3038)
[world] Guard hook_received against a concurrent run termination (#2987)
docs: fix stale/incorrect v5 API reference details (#3017)
Default WORKFLOW_PRECONDITION_GUARD on (#2946)
docs: replace migration guides with a Comparisons section (#2676)
feat(core): add experimental Hook minimum retention (#2865)
test: regression coverage for hook.resume() from isolated route bundles (o2flow beta.26 incident) (#3001)
docs(agents): note lint/format/typecheck are advisory, not blocking (#2886)
Retry transient connection timeouts (#3013)
fix(world-vercel): append caller User-Agent products instead of discarding them (#2998)
[ci] Enable NestJS e2e-vercel-prod and add to docs as "experimental" (#3011)
[ci] Benchmark comment: Best column + best/p75/p99 deltas (drop Avg/P10) (#3005)
docs: fall back to first child page for sidebar folders without an index (#3009)
[nest] Fix NestJS Vercel build output (#2988)
Avoid resolving run data for background steps (#2993)
chore(docs): update @vercel/geistdocs to 1.14.0 (#3002)
fix(docs): add version-switcher fallback redirects for pages missing in one version (#3003)
ci: update opencode to 1.18.4 and switch backport AI model to claude-fable-5 (#3006)
...
# Conflicts:
#	docs/content/docs/v4/deploying/meta.json
#	docs/content/docs/v5/deploying/meta.json
#	packages/core/src/runtime.ts
#	packages/world-local/src/index.ts
#	packages/world-postgres/src/index.ts
#	packages/world/src/events.ts
#	packages/world/src/interfaces.ts
#	packages/world/src/recovery.ts
#	workbench/nest/src/main.ts
#	workbench/sveltekit/src/hooks.server.ts
VaguelySerious added a commit that referenced this pull request Jul 31, 2026
Backport of #2631, adapted to stable. The v3 makeRequest path, the v4
events client, the stream write/close path and the direct Vercel API
calls each had their own request envelope; the 412 -> PreconditionFailed
and firewall-challenge-429 -> retryable TRANSPORT mappings were
duplicated between utils.ts and events-v4.ts.
http-core.ts now owns that envelope: the OTEL CLIENT span and its
attributes, W3C trace-context injection, the X-Request-Time cache bust,
the per-request deadline (mapped to a typed retryable WorkflowWorldError
instead of an opaque AbortError), DEBUG logging with a curl repro, and
the status -> typed-error mapping. utils.ts, events-v4.ts, streamer.ts,
encryption.ts and resolve-latest-deployment.ts delegate to it.
Behavioral gains on stable: the v4 events path and stream writes now
emit client spans and propagate traceparent, so the backend can parent
its spans to the caller without relying on the app having undici
auto-instrumentation.
Adapted rather than cherry-picked: stable keeps #3169's bounded
headers/body timeouts (documented as firing ahead of the outer
REQUEST_TIMEOUT_MS backstop), its body-parse retry loop, and #3038's
write-vs-close stream dispatcher split. Only injectTraceContextIntoHeaders
is taken from #2363, not its WORKFLOW_TRACE_MODE machinery.
Routing v4 through the global fetch makes the H2 interceptor's
re-buffering gate live on this path (fetch streamifies request bodies),
so the new http-client test drives createWorkflowRunEventV4 concurrently
against a local H2 server: 16 writes on one session with the
interceptor, 8 without it.
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

@karthikscale3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[world-vercel] Idempotent retry policy for stream close (5xx retriable) - #3038

Merged
karthikscale3 merged 3 commits into
mainfrom
kk/stream-close-retry
Jul 21, 2026
Merged

[world-vercel] Idempotent retry policy for stream close (5xx retriable)#3038
karthikscale3 merged 3 commits into
mainfrom
kk/stream-close-retry

Conversation

@karthikscale3

Copy link
Copy Markdown
Contributor

Stream close is the one idempotent stream PUT, and its retry policy now reflects that.

Why

Chunk appends deliberately never retry 5xx: a 5xx can mean the chunk was written but the response failed, and a retry would duplicate it (STREAM_RETRY_OPTIONS, unchanged here). Close is different:

  • A duplicate close of a completed stream is a server-side no-op, so re-sending it is always safe.
  • The server may surface transient close-time reconciliation states (e.g., storage backups still landing when the close is processed) as retriable 503s that expect the writer to close again — under the write dispatcher's no-5xx policy, that 503 rejected writer.close() outright, turning a self-healing timing condition into a user-visible stream error.

What changed

  • New STREAM_CLOSE_RETRY_OPTIONS and a dedicated shared RetryAgent for the close PUT: retries 429 + 500/502/503/504 plus transient connection errors, honoring Retry-After.
  • streamer.tsclose() switched to the new dispatcher. Chunk writes (write/writeMulti) keep the narrowed no-5xx policy unchanged.
  • Contract pinned by tests: 5xx present in the close retry set, absent from the write retry set, distinct shared dispatchers, caller-supplied dispatcher still respected.

Notes for reviewers

  • Close-as-fence semantics: once a close has been issued, a concurrent slow write to the same stream may be refused by the server rather than silently included — the changelog entry for the next release should carry a line to that effect.
  • All 299 world-vercel tests green; the change is confined to http-client.ts / streamer.ts plus tests and a changeset.

🤖 Generated with Claude Code

karthikscale3and others added 2 commits July 21, 2026 14:27
Stream close is the one idempotent stream PUT: a duplicate close of a
completed stream early-returns on the server, and the close-barrier
protocol's durable `closing` fence is an if_not_exists stamp that a
re-entered close resumes. The barrier protocol relies on close retrying
5xx: transient reconciliation failures — and unsafe close shapes
awaiting in-flight backups — surface as retriable 503s with the stream
left durably closing, expecting the writer to close again. Under the
write dispatcher's no-5xx policy (correct for non-idempotent chunk
appends), that 503 rejected writer.close() outright and left the stream
fenced until run expiry.
Close now uses its own shared RetryAgent (429 + 5xx + transient
connection errors, Retry-After honored); chunk writes keep the narrowed
no-5xx policy unchanged. Contract pinned by tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 21, 2026 21:28
@vercel

vercelBot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e9e1dee

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

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

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

@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 e9e1dee · Tue, 21 Jul 2026 21:45:42 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep156 (+41%) 🔻384 🔴 (+72%) 🔻679 🔴 (+67%) 🔻3201 🔴 (+509%) 🔻30
TTFSstream151 (+11%)412 🔴 (+43%) 🔻2524 🔴 (+597%) 🔻3641 🔴 (+353%) 🔻30
TTFShook + stream290 (-17%) 💚522 🔴 (+2.8%)647 🔴 (+12%)3042 🔴 (+393%) 🔻30
STSO1020 steps (1-20)226 (+32%) 🔻363 🔴 (+37%) 🔻467 🔴 (+45%) 🔻675 🔴 (+85%) 🔻19
STSO1020 steps (101-120)287 (+39%) 🔻426 🔴 (+40%) 🔻566 🔴 (+65%) 🔻632 🔴 (+34%) 🔻19
STSO1020 steps (1001-1020)446 (-24%) 💚542 🔴 (-24%) 💚629 🔴 (-19%) 💚744 🔴 (-9.3%)19
WO1020 steps551510 (+4.6%)551510 (+4.6%)551510 (+4.6%)551510 (+4.6%)1
SLstream latency92 (+21%) 🔻174 🔴 (+67%) 🔻194 🔴 (+17%) 🔻390 🔴 (+61%) 🔻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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 merged commit 59c1369 into mainJul 21, 2026
173 of 177 checks passed
@karthikscale3
karthikscale3 deleted the kk/stream-close-retry branch July 21, 2026 23:01
@github-actionsgithub-actionsBot mentioned this pull request Jul 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 59c1369 (AI decision).

This fix builds on the stream-specific dispatcher infrastructure (STREAM_RETRY_OPTIONS, getStreamDispatcher) that exists only on main — verified that stable's packages/world-vercel/src/http-client.ts has a single shared RetryAgent with none of these exports. On stable, that shared dispatcher already retries 5xx (500/502/503/504) for idempotent methods like the close PUT, so the bug being fixed (close rejected on retriable 503) does not exist there; it was a regression introduced by the main-only no-5xx stream write dispatcher.

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

59c13697c9919a4a96c012182e1adc0c58114ba5

pranaygp added a commit that referenced this pull request Jul 22, 2026
* origin/main: (162 commits)
Implement `max_events` per run limit (#2986)
[core] Enforce maxRetries for steps that time out (#3035)
[world-vercel] Idempotent retry policy for stream close (5xx retriable) (#3038)
[world] Guard hook_received against a concurrent run termination (#2987)
docs: fix stale/incorrect v5 API reference details (#3017)
Default WORKFLOW_PRECONDITION_GUARD on (#2946)
docs: replace migration guides with a Comparisons section (#2676)
feat(core): add experimental Hook minimum retention (#2865)
test: regression coverage for hook.resume() from isolated route bundles (o2flow beta.26 incident) (#3001)
docs(agents): note lint/format/typecheck are advisory, not blocking (#2886)
Retry transient connection timeouts (#3013)
fix(world-vercel): append caller User-Agent products instead of discarding them (#2998)
[ci] Enable NestJS e2e-vercel-prod and add to docs as "experimental" (#3011)
[ci] Benchmark comment: Best column + best/p75/p99 deltas (drop Avg/P10) (#3005)
docs: fall back to first child page for sidebar folders without an index (#3009)
[nest] Fix NestJS Vercel build output (#2988)
Avoid resolving run data for background steps (#2993)
chore(docs): update @vercel/geistdocs to 1.14.0 (#3002)
fix(docs): add version-switcher fallback redirects for pages missing in one version (#3003)
ci: update opencode to 1.18.4 and switch backport AI model to claude-fable-5 (#3006)
...
# Conflicts:
#	docs/content/docs/v4/deploying/meta.json
#	docs/content/docs/v5/deploying/meta.json
#	packages/core/src/runtime.ts
#	packages/world-local/src/index.ts
#	packages/world-postgres/src/index.ts
#	packages/world/src/events.ts
#	packages/world/src/interfaces.ts
#	packages/world/src/recovery.ts
#	workbench/nest/src/main.ts
#	workbench/sveltekit/src/hooks.server.ts
VaguelySerious added a commit that referenced this pull request Jul 31, 2026
Backport of #2631, adapted to stable. The v3 makeRequest path, the v4
events client, the stream write/close path and the direct Vercel API
calls each had their own request envelope; the 412 -> PreconditionFailed
and firewall-challenge-429 -> retryable TRANSPORT mappings were
duplicated between utils.ts and events-v4.ts.
http-core.ts now owns that envelope: the OTEL CLIENT span and its
attributes, W3C trace-context injection, the X-Request-Time cache bust,
the per-request deadline (mapped to a typed retryable WorkflowWorldError
instead of an opaque AbortError), DEBUG logging with a curl repro, and
the status -> typed-error mapping. utils.ts, events-v4.ts, streamer.ts,
encryption.ts and resolve-latest-deployment.ts delegate to it.
Behavioral gains on stable: the v4 events path and stream writes now
emit client spans and propagate traceparent, so the backend can parent
its spans to the caller without relying on the app having undici
auto-instrumentation.
Adapted rather than cherry-picked: stable keeps #3169's bounded
headers/body timeouts (documented as firing ahead of the outer
REQUEST_TIMEOUT_MS backstop), its body-parse retry loop, and #3038's
write-vs-close stream dispatcher split. Only injectTraceContextIntoHeaders
is taken from #2363, not its WORKFLOW_TRACE_MODE machinery.
Routing v4 through the global fetch makes the H2 interceptor's
re-buffering gate live on this path (fetch streamifies request bodies),
so the new http-client test drives createWorkflowRunEventV4 concurrently
against a local H2 server: 16 writes on one session with the
interceptor, 8 without it.
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

@karthikscale3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

[world-vercel] Idempotent retry policy for stream close (5xx retriable) - #3038

Merged
karthikscale3 merged 3 commits into
mainfrom
kk/stream-close-retry
Jul 21, 2026
Merged

[world-vercel] Idempotent retry policy for stream close (5xx retriable)#3038
karthikscale3 merged 3 commits into
mainfrom
kk/stream-close-retry

Conversation

@karthikscale3

Copy link
Copy Markdown
Contributor

Stream close is the one idempotent stream PUT, and its retry policy now reflects that.

Why

Chunk appends deliberately never retry 5xx: a 5xx can mean the chunk was written but the response failed, and a retry would duplicate it (STREAM_RETRY_OPTIONS, unchanged here). Close is different:

  • A duplicate close of a completed stream is a server-side no-op, so re-sending it is always safe.
  • The server may surface transient close-time reconciliation states (e.g., storage backups still landing when the close is processed) as retriable 503s that expect the writer to close again — under the write dispatcher's no-5xx policy, that 503 rejected writer.close() outright, turning a self-healing timing condition into a user-visible stream error.

What changed

  • New STREAM_CLOSE_RETRY_OPTIONS and a dedicated shared RetryAgent for the close PUT: retries 429 + 500/502/503/504 plus transient connection errors, honoring Retry-After.
  • streamer.tsclose() switched to the new dispatcher. Chunk writes (write/writeMulti) keep the narrowed no-5xx policy unchanged.
  • Contract pinned by tests: 5xx present in the close retry set, absent from the write retry set, distinct shared dispatchers, caller-supplied dispatcher still respected.

Notes for reviewers

  • Close-as-fence semantics: once a close has been issued, a concurrent slow write to the same stream may be refused by the server rather than silently included — the changelog entry for the next release should carry a line to that effect.
  • All 299 world-vercel tests green; the change is confined to http-client.ts / streamer.ts plus tests and a changeset.

🤖 Generated with Claude Code

karthikscale3and others added 2 commits July 21, 2026 14:27
Stream close is the one idempotent stream PUT: a duplicate close of a
completed stream early-returns on the server, and the close-barrier
protocol's durable `closing` fence is an if_not_exists stamp that a
re-entered close resumes. The barrier protocol relies on close retrying
5xx: transient reconciliation failures — and unsafe close shapes
awaiting in-flight backups — surface as retriable 503s with the stream
left durably closing, expecting the writer to close again. Under the
write dispatcher's no-5xx policy (correct for non-idempotent chunk
appends), that 503 rejected writer.close() outright and left the stream
fenced until run expiry.
Close now uses its own shared RetryAgent (429 + 5xx + transient
connection errors, Retry-After honored); chunk writes keep the narrowed
no-5xx policy unchanged. Contract pinned by tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 21, 2026 21:28
@vercel

vercelBot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e9e1dee

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

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

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

@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 e9e1dee · Tue, 21 Jul 2026 21:45:42 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep156 (+41%) 🔻384 🔴 (+72%) 🔻679 🔴 (+67%) 🔻3201 🔴 (+509%) 🔻30
TTFSstream151 (+11%)412 🔴 (+43%) 🔻2524 🔴 (+597%) 🔻3641 🔴 (+353%) 🔻30
TTFShook + stream290 (-17%) 💚522 🔴 (+2.8%)647 🔴 (+12%)3042 🔴 (+393%) 🔻30
STSO1020 steps (1-20)226 (+32%) 🔻363 🔴 (+37%) 🔻467 🔴 (+45%) 🔻675 🔴 (+85%) 🔻19
STSO1020 steps (101-120)287 (+39%) 🔻426 🔴 (+40%) 🔻566 🔴 (+65%) 🔻632 🔴 (+34%) 🔻19
STSO1020 steps (1001-1020)446 (-24%) 💚542 🔴 (-24%) 💚629 🔴 (-19%) 💚744 🔴 (-9.3%)19
WO1020 steps551510 (+4.6%)551510 (+4.6%)551510 (+4.6%)551510 (+4.6%)1
SLstream latency92 (+21%) 🔻174 🔴 (+67%) 🔻194 🔴 (+17%) 🔻390 🔴 (+61%) 🔻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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 merged commit 59c1369 into mainJul 21, 2026
173 of 177 checks passed
@karthikscale3
karthikscale3 deleted the kk/stream-close-retry branch July 21, 2026 23:01
@github-actionsgithub-actionsBot mentioned this pull request Jul 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 59c1369 (AI decision).

This fix builds on the stream-specific dispatcher infrastructure (STREAM_RETRY_OPTIONS, getStreamDispatcher) that exists only on main — verified that stable's packages/world-vercel/src/http-client.ts has a single shared RetryAgent with none of these exports. On stable, that shared dispatcher already retries 5xx (500/502/503/504) for idempotent methods like the close PUT, so the bug being fixed (close rejected on retriable 503) does not exist there; it was a regression introduced by the main-only no-5xx stream write dispatcher.

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

59c13697c9919a4a96c012182e1adc0c58114ba5

pranaygp added a commit that referenced this pull request Jul 22, 2026
* origin/main: (162 commits)
Implement `max_events` per run limit (#2986)
[core] Enforce maxRetries for steps that time out (#3035)
[world-vercel] Idempotent retry policy for stream close (5xx retriable) (#3038)
[world] Guard hook_received against a concurrent run termination (#2987)
docs: fix stale/incorrect v5 API reference details (#3017)
Default WORKFLOW_PRECONDITION_GUARD on (#2946)
docs: replace migration guides with a Comparisons section (#2676)
feat(core): add experimental Hook minimum retention (#2865)
test: regression coverage for hook.resume() from isolated route bundles (o2flow beta.26 incident) (#3001)
docs(agents): note lint/format/typecheck are advisory, not blocking (#2886)
Retry transient connection timeouts (#3013)
fix(world-vercel): append caller User-Agent products instead of discarding them (#2998)
[ci] Enable NestJS e2e-vercel-prod and add to docs as "experimental" (#3011)
[ci] Benchmark comment: Best column + best/p75/p99 deltas (drop Avg/P10) (#3005)
docs: fall back to first child page for sidebar folders without an index (#3009)
[nest] Fix NestJS Vercel build output (#2988)
Avoid resolving run data for background steps (#2993)
chore(docs): update @vercel/geistdocs to 1.14.0 (#3002)
fix(docs): add version-switcher fallback redirects for pages missing in one version (#3003)
ci: update opencode to 1.18.4 and switch backport AI model to claude-fable-5 (#3006)
...
# Conflicts:
#	docs/content/docs/v4/deploying/meta.json
#	docs/content/docs/v5/deploying/meta.json
#	packages/core/src/runtime.ts
#	packages/world-local/src/index.ts
#	packages/world-postgres/src/index.ts
#	packages/world/src/events.ts
#	packages/world/src/interfaces.ts
#	packages/world/src/recovery.ts
#	workbench/nest/src/main.ts
#	workbench/sveltekit/src/hooks.server.ts
VaguelySerious added a commit that referenced this pull request Jul 31, 2026
Backport of #2631, adapted to stable. The v3 makeRequest path, the v4
events client, the stream write/close path and the direct Vercel API
calls each had their own request envelope; the 412 -> PreconditionFailed
and firewall-challenge-429 -> retryable TRANSPORT mappings were
duplicated between utils.ts and events-v4.ts.
http-core.ts now owns that envelope: the OTEL CLIENT span and its
attributes, W3C trace-context injection, the X-Request-Time cache bust,
the per-request deadline (mapped to a typed retryable WorkflowWorldError
instead of an opaque AbortError), DEBUG logging with a curl repro, and
the status -> typed-error mapping. utils.ts, events-v4.ts, streamer.ts,
encryption.ts and resolve-latest-deployment.ts delegate to it.
Behavioral gains on stable: the v4 events path and stream writes now
emit client spans and propagate traceparent, so the backend can parent
its spans to the caller without relying on the app having undici
auto-instrumentation.
Adapted rather than cherry-picked: stable keeps #3169's bounded
headers/body timeouts (documented as firing ahead of the outer
REQUEST_TIMEOUT_MS backstop), its body-parse retry loop, and #3038's
write-vs-close stream dispatcher split. Only injectTraceContextIntoHeaders
is taken from #2363, not its WORKFLOW_TRACE_MODE machinery.
Routing v4 through the global fetch makes the H2 interceptor's
re-buffering gate live on this path (fetch streamifies request bodies),
so the new http-client test drives createWorkflowRunEventV4 concurrently
against a local H2 server: 16 writes on one session with the
interceptor, 8 without it.
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

@karthikscale3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[world-vercel] Idempotent retry policy for stream close (5xx retriable) - #3038

Merged
karthikscale3 merged 3 commits into
mainfrom
kk/stream-close-retry
Jul 21, 2026
Merged

[world-vercel] Idempotent retry policy for stream close (5xx retriable)#3038
karthikscale3 merged 3 commits into
mainfrom
kk/stream-close-retry

Conversation

@karthikscale3

Copy link
Copy Markdown
Contributor

Stream close is the one idempotent stream PUT, and its retry policy now reflects that.

Why

Chunk appends deliberately never retry 5xx: a 5xx can mean the chunk was written but the response failed, and a retry would duplicate it (STREAM_RETRY_OPTIONS, unchanged here). Close is different:

  • A duplicate close of a completed stream is a server-side no-op, so re-sending it is always safe.
  • The server may surface transient close-time reconciliation states (e.g., storage backups still landing when the close is processed) as retriable 503s that expect the writer to close again — under the write dispatcher's no-5xx policy, that 503 rejected writer.close() outright, turning a self-healing timing condition into a user-visible stream error.

What changed

  • New STREAM_CLOSE_RETRY_OPTIONS and a dedicated shared RetryAgent for the close PUT: retries 429 + 500/502/503/504 plus transient connection errors, honoring Retry-After.
  • streamer.tsclose() switched to the new dispatcher. Chunk writes (write/writeMulti) keep the narrowed no-5xx policy unchanged.
  • Contract pinned by tests: 5xx present in the close retry set, absent from the write retry set, distinct shared dispatchers, caller-supplied dispatcher still respected.

Notes for reviewers

  • Close-as-fence semantics: once a close has been issued, a concurrent slow write to the same stream may be refused by the server rather than silently included — the changelog entry for the next release should carry a line to that effect.
  • All 299 world-vercel tests green; the change is confined to http-client.ts / streamer.ts plus tests and a changeset.

🤖 Generated with Claude Code

karthikscale3and others added 2 commits July 21, 2026 14:27
Stream close is the one idempotent stream PUT: a duplicate close of a
completed stream early-returns on the server, and the close-barrier
protocol's durable `closing` fence is an if_not_exists stamp that a
re-entered close resumes. The barrier protocol relies on close retrying
5xx: transient reconciliation failures — and unsafe close shapes
awaiting in-flight backups — surface as retriable 503s with the stream
left durably closing, expecting the writer to close again. Under the
write dispatcher's no-5xx policy (correct for non-idempotent chunk
appends), that 503 rejected writer.close() outright and left the stream
fenced until run expiry.
Close now uses its own shared RetryAgent (429 + 5xx + transient
connection errors, Retry-After honored); chunk writes keep the narrowed
no-5xx policy unchanged. Contract pinned by tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 21, 2026 21:28
@vercel

vercelBot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e9e1dee

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

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

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

@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 e9e1dee · Tue, 21 Jul 2026 21:45:42 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep156 (+41%) 🔻384 🔴 (+72%) 🔻679 🔴 (+67%) 🔻3201 🔴 (+509%) 🔻30
TTFSstream151 (+11%)412 🔴 (+43%) 🔻2524 🔴 (+597%) 🔻3641 🔴 (+353%) 🔻30
TTFShook + stream290 (-17%) 💚522 🔴 (+2.8%)647 🔴 (+12%)3042 🔴 (+393%) 🔻30
STSO1020 steps (1-20)226 (+32%) 🔻363 🔴 (+37%) 🔻467 🔴 (+45%) 🔻675 🔴 (+85%) 🔻19
STSO1020 steps (101-120)287 (+39%) 🔻426 🔴 (+40%) 🔻566 🔴 (+65%) 🔻632 🔴 (+34%) 🔻19
STSO1020 steps (1001-1020)446 (-24%) 💚542 🔴 (-24%) 💚629 🔴 (-19%) 💚744 🔴 (-9.3%)19
WO1020 steps551510 (+4.6%)551510 (+4.6%)551510 (+4.6%)551510 (+4.6%)1
SLstream latency92 (+21%) 🔻174 🔴 (+67%) 🔻194 🔴 (+17%) 🔻390 🔴 (+61%) 🔻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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 merged commit 59c1369 into mainJul 21, 2026
173 of 177 checks passed
@karthikscale3
karthikscale3 deleted the kk/stream-close-retry branch July 21, 2026 23:01
@github-actionsgithub-actionsBot mentioned this pull request Jul 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 59c1369 (AI decision).

This fix builds on the stream-specific dispatcher infrastructure (STREAM_RETRY_OPTIONS, getStreamDispatcher) that exists only on main — verified that stable's packages/world-vercel/src/http-client.ts has a single shared RetryAgent with none of these exports. On stable, that shared dispatcher already retries 5xx (500/502/503/504) for idempotent methods like the close PUT, so the bug being fixed (close rejected on retriable 503) does not exist there; it was a regression introduced by the main-only no-5xx stream write dispatcher.

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

59c13697c9919a4a96c012182e1adc0c58114ba5

pranaygp added a commit that referenced this pull request Jul 22, 2026
* origin/main: (162 commits)
Implement `max_events` per run limit (#2986)
[core] Enforce maxRetries for steps that time out (#3035)
[world-vercel] Idempotent retry policy for stream close (5xx retriable) (#3038)
[world] Guard hook_received against a concurrent run termination (#2987)
docs: fix stale/incorrect v5 API reference details (#3017)
Default WORKFLOW_PRECONDITION_GUARD on (#2946)
docs: replace migration guides with a Comparisons section (#2676)
feat(core): add experimental Hook minimum retention (#2865)
test: regression coverage for hook.resume() from isolated route bundles (o2flow beta.26 incident) (#3001)
docs(agents): note lint/format/typecheck are advisory, not blocking (#2886)
Retry transient connection timeouts (#3013)
fix(world-vercel): append caller User-Agent products instead of discarding them (#2998)
[ci] Enable NestJS e2e-vercel-prod and add to docs as "experimental" (#3011)
[ci] Benchmark comment: Best column + best/p75/p99 deltas (drop Avg/P10) (#3005)
docs: fall back to first child page for sidebar folders without an index (#3009)
[nest] Fix NestJS Vercel build output (#2988)
Avoid resolving run data for background steps (#2993)
chore(docs): update @vercel/geistdocs to 1.14.0 (#3002)
fix(docs): add version-switcher fallback redirects for pages missing in one version (#3003)
ci: update opencode to 1.18.4 and switch backport AI model to claude-fable-5 (#3006)
...
# Conflicts:
#	docs/content/docs/v4/deploying/meta.json
#	docs/content/docs/v5/deploying/meta.json
#	packages/core/src/runtime.ts
#	packages/world-local/src/index.ts
#	packages/world-postgres/src/index.ts
#	packages/world/src/events.ts
#	packages/world/src/interfaces.ts
#	packages/world/src/recovery.ts
#	workbench/nest/src/main.ts
#	workbench/sveltekit/src/hooks.server.ts
VaguelySerious added a commit that referenced this pull request Jul 31, 2026
Backport of #2631, adapted to stable. The v3 makeRequest path, the v4
events client, the stream write/close path and the direct Vercel API
calls each had their own request envelope; the 412 -> PreconditionFailed
and firewall-challenge-429 -> retryable TRANSPORT mappings were
duplicated between utils.ts and events-v4.ts.
http-core.ts now owns that envelope: the OTEL CLIENT span and its
attributes, W3C trace-context injection, the X-Request-Time cache bust,
the per-request deadline (mapped to a typed retryable WorkflowWorldError
instead of an opaque AbortError), DEBUG logging with a curl repro, and
the status -> typed-error mapping. utils.ts, events-v4.ts, streamer.ts,
encryption.ts and resolve-latest-deployment.ts delegate to it.
Behavioral gains on stable: the v4 events path and stream writes now
emit client spans and propagate traceparent, so the backend can parent
its spans to the caller without relying on the app having undici
auto-instrumentation.
Adapted rather than cherry-picked: stable keeps #3169's bounded
headers/body timeouts (documented as firing ahead of the outer
REQUEST_TIMEOUT_MS backstop), its body-parse retry loop, and #3038's
write-vs-close stream dispatcher split. Only injectTraceContextIntoHeaders
is taken from #2363, not its WORKFLOW_TRACE_MODE machinery.
Routing v4 through the global fetch makes the H2 interceptor's
re-buffering gate live on this path (fetch streamifies request bodies),
so the new http-client test drives createWorkflowRunEventV4 concurrently
against a local H2 server: 16 writes on one session with the
interceptor, 8 without it.
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

@karthikscale3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[world-vercel] Idempotent retry policy for stream close (5xx retriable) - #3038

Merged
karthikscale3 merged 3 commits into
mainfrom
kk/stream-close-retry
Jul 21, 2026
Merged

[world-vercel] Idempotent retry policy for stream close (5xx retriable)#3038
karthikscale3 merged 3 commits into
mainfrom
kk/stream-close-retry

Conversation

@karthikscale3

Copy link
Copy Markdown
Contributor

Stream close is the one idempotent stream PUT, and its retry policy now reflects that.

Why

Chunk appends deliberately never retry 5xx: a 5xx can mean the chunk was written but the response failed, and a retry would duplicate it (STREAM_RETRY_OPTIONS, unchanged here). Close is different:

  • A duplicate close of a completed stream is a server-side no-op, so re-sending it is always safe.
  • The server may surface transient close-time reconciliation states (e.g., storage backups still landing when the close is processed) as retriable 503s that expect the writer to close again — under the write dispatcher's no-5xx policy, that 503 rejected writer.close() outright, turning a self-healing timing condition into a user-visible stream error.

What changed

  • New STREAM_CLOSE_RETRY_OPTIONS and a dedicated shared RetryAgent for the close PUT: retries 429 + 500/502/503/504 plus transient connection errors, honoring Retry-After.
  • streamer.tsclose() switched to the new dispatcher. Chunk writes (write/writeMulti) keep the narrowed no-5xx policy unchanged.
  • Contract pinned by tests: 5xx present in the close retry set, absent from the write retry set, distinct shared dispatchers, caller-supplied dispatcher still respected.

Notes for reviewers

  • Close-as-fence semantics: once a close has been issued, a concurrent slow write to the same stream may be refused by the server rather than silently included — the changelog entry for the next release should carry a line to that effect.
  • All 299 world-vercel tests green; the change is confined to http-client.ts / streamer.ts plus tests and a changeset.

🤖 Generated with Claude Code

karthikscale3and others added 2 commits July 21, 2026 14:27
Stream close is the one idempotent stream PUT: a duplicate close of a
completed stream early-returns on the server, and the close-barrier
protocol's durable `closing` fence is an if_not_exists stamp that a
re-entered close resumes. The barrier protocol relies on close retrying
5xx: transient reconciliation failures — and unsafe close shapes
awaiting in-flight backups — surface as retriable 503s with the stream
left durably closing, expecting the writer to close again. Under the
write dispatcher's no-5xx policy (correct for non-idempotent chunk
appends), that 503 rejected writer.close() outright and left the stream
fenced until run expiry.
Close now uses its own shared RetryAgent (429 + 5xx + transient
connection errors, Retry-After honored); chunk writes keep the narrowed
no-5xx policy unchanged. Contract pinned by tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 21, 2026 21:28
@vercel

vercelBot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e9e1dee

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

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

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

@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 e9e1dee · Tue, 21 Jul 2026 21:45:42 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep156 (+41%) 🔻384 🔴 (+72%) 🔻679 🔴 (+67%) 🔻3201 🔴 (+509%) 🔻30
TTFSstream151 (+11%)412 🔴 (+43%) 🔻2524 🔴 (+597%) 🔻3641 🔴 (+353%) 🔻30
TTFShook + stream290 (-17%) 💚522 🔴 (+2.8%)647 🔴 (+12%)3042 🔴 (+393%) 🔻30
STSO1020 steps (1-20)226 (+32%) 🔻363 🔴 (+37%) 🔻467 🔴 (+45%) 🔻675 🔴 (+85%) 🔻19
STSO1020 steps (101-120)287 (+39%) 🔻426 🔴 (+40%) 🔻566 🔴 (+65%) 🔻632 🔴 (+34%) 🔻19
STSO1020 steps (1001-1020)446 (-24%) 💚542 🔴 (-24%) 💚629 🔴 (-19%) 💚744 🔴 (-9.3%)19
WO1020 steps551510 (+4.6%)551510 (+4.6%)551510 (+4.6%)551510 (+4.6%)1
SLstream latency92 (+21%) 🔻174 🔴 (+67%) 🔻194 🔴 (+17%) 🔻390 🔴 (+61%) 🔻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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 merged commit 59c1369 into mainJul 21, 2026
173 of 177 checks passed
@karthikscale3
karthikscale3 deleted the kk/stream-close-retry branch July 21, 2026 23:01
@github-actionsgithub-actionsBot mentioned this pull request Jul 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 59c1369 (AI decision).

This fix builds on the stream-specific dispatcher infrastructure (STREAM_RETRY_OPTIONS, getStreamDispatcher) that exists only on main — verified that stable's packages/world-vercel/src/http-client.ts has a single shared RetryAgent with none of these exports. On stable, that shared dispatcher already retries 5xx (500/502/503/504) for idempotent methods like the close PUT, so the bug being fixed (close rejected on retriable 503) does not exist there; it was a regression introduced by the main-only no-5xx stream write dispatcher.

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

59c13697c9919a4a96c012182e1adc0c58114ba5

pranaygp added a commit that referenced this pull request Jul 22, 2026
* origin/main: (162 commits)
Implement `max_events` per run limit (#2986)
[core] Enforce maxRetries for steps that time out (#3035)
[world-vercel] Idempotent retry policy for stream close (5xx retriable) (#3038)
[world] Guard hook_received against a concurrent run termination (#2987)
docs: fix stale/incorrect v5 API reference details (#3017)
Default WORKFLOW_PRECONDITION_GUARD on (#2946)
docs: replace migration guides with a Comparisons section (#2676)
feat(core): add experimental Hook minimum retention (#2865)
test: regression coverage for hook.resume() from isolated route bundles (o2flow beta.26 incident) (#3001)
docs(agents): note lint/format/typecheck are advisory, not blocking (#2886)
Retry transient connection timeouts (#3013)
fix(world-vercel): append caller User-Agent products instead of discarding them (#2998)
[ci] Enable NestJS e2e-vercel-prod and add to docs as "experimental" (#3011)
[ci] Benchmark comment: Best column + best/p75/p99 deltas (drop Avg/P10) (#3005)
docs: fall back to first child page for sidebar folders without an index (#3009)
[nest] Fix NestJS Vercel build output (#2988)
Avoid resolving run data for background steps (#2993)
chore(docs): update @vercel/geistdocs to 1.14.0 (#3002)
fix(docs): add version-switcher fallback redirects for pages missing in one version (#3003)
ci: update opencode to 1.18.4 and switch backport AI model to claude-fable-5 (#3006)
...
# Conflicts:
#	docs/content/docs/v4/deploying/meta.json
#	docs/content/docs/v5/deploying/meta.json
#	packages/core/src/runtime.ts
#	packages/world-local/src/index.ts
#	packages/world-postgres/src/index.ts
#	packages/world/src/events.ts
#	packages/world/src/interfaces.ts
#	packages/world/src/recovery.ts
#	workbench/nest/src/main.ts
#	workbench/sveltekit/src/hooks.server.ts
VaguelySerious added a commit that referenced this pull request Jul 31, 2026
Backport of #2631, adapted to stable. The v3 makeRequest path, the v4
events client, the stream write/close path and the direct Vercel API
calls each had their own request envelope; the 412 -> PreconditionFailed
and firewall-challenge-429 -> retryable TRANSPORT mappings were
duplicated between utils.ts and events-v4.ts.
http-core.ts now owns that envelope: the OTEL CLIENT span and its
attributes, W3C trace-context injection, the X-Request-Time cache bust,
the per-request deadline (mapped to a typed retryable WorkflowWorldError
instead of an opaque AbortError), DEBUG logging with a curl repro, and
the status -> typed-error mapping. utils.ts, events-v4.ts, streamer.ts,
encryption.ts and resolve-latest-deployment.ts delegate to it.
Behavioral gains on stable: the v4 events path and stream writes now
emit client spans and propagate traceparent, so the backend can parent
its spans to the caller without relying on the app having undici
auto-instrumentation.
Adapted rather than cherry-picked: stable keeps #3169's bounded
headers/body timeouts (documented as firing ahead of the outer
REQUEST_TIMEOUT_MS backstop), its body-parse retry loop, and #3038's
write-vs-close stream dispatcher split. Only injectTraceContextIntoHeaders
is taken from #2363, not its WORKFLOW_TRACE_MODE machinery.
Routing v4 through the global fetch makes the H2 interceptor's
re-buffering gate live on this path (fetch streamifies request bodies),
so the new http-client test drives createWorkflowRunEventV4 concurrently
against a local H2 server: 16 writes on one session with the
interceptor, 8 without it.
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

@karthikscale3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

[world-vercel] Idempotent retry policy for stream close (5xx retriable) - #3038

Merged
karthikscale3 merged 3 commits into
mainfrom
kk/stream-close-retry
Jul 21, 2026
Merged

[world-vercel] Idempotent retry policy for stream close (5xx retriable)#3038
karthikscale3 merged 3 commits into
mainfrom
kk/stream-close-retry

Conversation

@karthikscale3

Copy link
Copy Markdown
Contributor

Stream close is the one idempotent stream PUT, and its retry policy now reflects that.

Why

Chunk appends deliberately never retry 5xx: a 5xx can mean the chunk was written but the response failed, and a retry would duplicate it (STREAM_RETRY_OPTIONS, unchanged here). Close is different:

  • A duplicate close of a completed stream is a server-side no-op, so re-sending it is always safe.
  • The server may surface transient close-time reconciliation states (e.g., storage backups still landing when the close is processed) as retriable 503s that expect the writer to close again — under the write dispatcher's no-5xx policy, that 503 rejected writer.close() outright, turning a self-healing timing condition into a user-visible stream error.

What changed

  • New STREAM_CLOSE_RETRY_OPTIONS and a dedicated shared RetryAgent for the close PUT: retries 429 + 500/502/503/504 plus transient connection errors, honoring Retry-After.
  • streamer.tsclose() switched to the new dispatcher. Chunk writes (write/writeMulti) keep the narrowed no-5xx policy unchanged.
  • Contract pinned by tests: 5xx present in the close retry set, absent from the write retry set, distinct shared dispatchers, caller-supplied dispatcher still respected.

Notes for reviewers

  • Close-as-fence semantics: once a close has been issued, a concurrent slow write to the same stream may be refused by the server rather than silently included — the changelog entry for the next release should carry a line to that effect.
  • All 299 world-vercel tests green; the change is confined to http-client.ts / streamer.ts plus tests and a changeset.

🤖 Generated with Claude Code

karthikscale3and others added 2 commits July 21, 2026 14:27
Stream close is the one idempotent stream PUT: a duplicate close of a
completed stream early-returns on the server, and the close-barrier
protocol's durable `closing` fence is an if_not_exists stamp that a
re-entered close resumes. The barrier protocol relies on close retrying
5xx: transient reconciliation failures — and unsafe close shapes
awaiting in-flight backups — surface as retriable 503s with the stream
left durably closing, expecting the writer to close again. Under the
write dispatcher's no-5xx policy (correct for non-idempotent chunk
appends), that 503 rejected writer.close() outright and left the stream
fenced until run expiry.
Close now uses its own shared RetryAgent (429 + 5xx + transient
connection errors, Retry-After honored); chunk writes keep the narrowed
no-5xx policy unchanged. Contract pinned by tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 21, 2026 21:28
@vercel

vercelBot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e9e1dee

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

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

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

@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 e9e1dee · Tue, 21 Jul 2026 21:45:42 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep156 (+41%) 🔻384 🔴 (+72%) 🔻679 🔴 (+67%) 🔻3201 🔴 (+509%) 🔻30
TTFSstream151 (+11%)412 🔴 (+43%) 🔻2524 🔴 (+597%) 🔻3641 🔴 (+353%) 🔻30
TTFShook + stream290 (-17%) 💚522 🔴 (+2.8%)647 🔴 (+12%)3042 🔴 (+393%) 🔻30
STSO1020 steps (1-20)226 (+32%) 🔻363 🔴 (+37%) 🔻467 🔴 (+45%) 🔻675 🔴 (+85%) 🔻19
STSO1020 steps (101-120)287 (+39%) 🔻426 🔴 (+40%) 🔻566 🔴 (+65%) 🔻632 🔴 (+34%) 🔻19
STSO1020 steps (1001-1020)446 (-24%) 💚542 🔴 (-24%) 💚629 🔴 (-19%) 💚744 🔴 (-9.3%)19
WO1020 steps551510 (+4.6%)551510 (+4.6%)551510 (+4.6%)551510 (+4.6%)1
SLstream latency92 (+21%) 🔻174 🔴 (+67%) 🔻194 🔴 (+17%) 🔻390 🔴 (+61%) 🔻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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karthikscale3
karthikscale3 merged commit 59c1369 into mainJul 21, 2026
173 of 177 checks passed
@karthikscale3
karthikscale3 deleted the kk/stream-close-retry branch July 21, 2026 23:01
@github-actionsgithub-actionsBot mentioned this pull request Jul 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 59c1369 (AI decision).

This fix builds on the stream-specific dispatcher infrastructure (STREAM_RETRY_OPTIONS, getStreamDispatcher) that exists only on main — verified that stable's packages/world-vercel/src/http-client.ts has a single shared RetryAgent with none of these exports. On stable, that shared dispatcher already retries 5xx (500/502/503/504) for idempotent methods like the close PUT, so the bug being fixed (close rejected on retriable 503) does not exist there; it was a regression introduced by the main-only no-5xx stream write dispatcher.

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

59c13697c9919a4a96c012182e1adc0c58114ba5

pranaygp added a commit that referenced this pull request Jul 22, 2026
* origin/main: (162 commits)
Implement `max_events` per run limit (#2986)
[core] Enforce maxRetries for steps that time out (#3035)
[world-vercel] Idempotent retry policy for stream close (5xx retriable) (#3038)
[world] Guard hook_received against a concurrent run termination (#2987)
docs: fix stale/incorrect v5 API reference details (#3017)
Default WORKFLOW_PRECONDITION_GUARD on (#2946)
docs: replace migration guides with a Comparisons section (#2676)
feat(core): add experimental Hook minimum retention (#2865)
test: regression coverage for hook.resume() from isolated route bundles (o2flow beta.26 incident) (#3001)
docs(agents): note lint/format/typecheck are advisory, not blocking (#2886)
Retry transient connection timeouts (#3013)
fix(world-vercel): append caller User-Agent products instead of discarding them (#2998)
[ci] Enable NestJS e2e-vercel-prod and add to docs as "experimental" (#3011)
[ci] Benchmark comment: Best column + best/p75/p99 deltas (drop Avg/P10) (#3005)
docs: fall back to first child page for sidebar folders without an index (#3009)
[nest] Fix NestJS Vercel build output (#2988)
Avoid resolving run data for background steps (#2993)
chore(docs): update @vercel/geistdocs to 1.14.0 (#3002)
fix(docs): add version-switcher fallback redirects for pages missing in one version (#3003)
ci: update opencode to 1.18.4 and switch backport AI model to claude-fable-5 (#3006)
...
# Conflicts:
#	docs/content/docs/v4/deploying/meta.json
#	docs/content/docs/v5/deploying/meta.json
#	packages/core/src/runtime.ts
#	packages/world-local/src/index.ts
#	packages/world-postgres/src/index.ts
#	packages/world/src/events.ts
#	packages/world/src/interfaces.ts
#	packages/world/src/recovery.ts
#	workbench/nest/src/main.ts
#	workbench/sveltekit/src/hooks.server.ts
VaguelySerious added a commit that referenced this pull request Jul 31, 2026
Backport of #2631, adapted to stable. The v3 makeRequest path, the v4
events client, the stream write/close path and the direct Vercel API
calls each had their own request envelope; the 412 -> PreconditionFailed
and firewall-challenge-429 -> retryable TRANSPORT mappings were
duplicated between utils.ts and events-v4.ts.
http-core.ts now owns that envelope: the OTEL CLIENT span and its
attributes, W3C trace-context injection, the X-Request-Time cache bust,
the per-request deadline (mapped to a typed retryable WorkflowWorldError
instead of an opaque AbortError), DEBUG logging with a curl repro, and
the status -> typed-error mapping. utils.ts, events-v4.ts, streamer.ts,
encryption.ts and resolve-latest-deployment.ts delegate to it.
Behavioral gains on stable: the v4 events path and stream writes now
emit client spans and propagate traceparent, so the backend can parent
its spans to the caller without relying on the app having undici
auto-instrumentation.
Adapted rather than cherry-picked: stable keeps #3169's bounded
headers/body timeouts (documented as firing ahead of the outer
REQUEST_TIMEOUT_MS backstop), its body-parse retry loop, and #3038's
write-vs-close stream dispatcher split. Only injectTraceContextIntoHeaders
is taken from #2363, not its WORKFLOW_TRACE_MODE machinery.
Routing v4 through the global fetch makes the H2 interceptor's
re-buffering gate live on this path (fetch streamifies request bodies),
so the new http-client test drives createWorkflowRunEventV4 concurrently
against a local H2 server: 16 writes on one session with the
interceptor, 8 without it.
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

@karthikscale3@VaguelySerious