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.ts close() 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

karthikscale3 and 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 owners July 21, 2026 21:28
@vercel

vercel Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview, Comment Jul 21, 2026 9:33pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jul 21, 2026 9:33pm
example-workflow Ready Ready Preview, Comment Jul 21, 2026 9:33pm
workbench-astro-workflow Ready Ready Preview, Comment Jul 21, 2026 9:33pm
workbench-express-workflow Ready Ready Preview, Comment Jul 21, 2026 9:33pm
workbench-fastify-workflow Ready Ready Preview, Comment Jul 21, 2026 9:33pm
workbench-hono-workflow Building Building Preview, Comment Jul 21, 2026 9:33pm
workbench-nestjs-workflow Ready Ready Preview, Comment Jul 21, 2026 9:33pm
workbench-nitro-workflow Ready Ready Preview, Comment Jul 21, 2026 9:33pm
workbench-nuxt-workflow Ready Ready Preview, Comment Jul 21, 2026 9:33pm
workbench-sveltekit-workflow Ready Ready Preview, Comment Jul 21, 2026 9:33pm
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jul 21, 2026 9:33pm
workbench-vite-workflow Ready Ready Preview, Comment Jul 21, 2026 9:33pm
workflow-docs Ready Ready Preview, Comment, Open in v0 Jul 21, 2026 9:33pm
workflow-swc-playground Ready Ready Preview, Comment Jul 21, 2026 9:33pm
workflow-tarballs Ready Ready Preview, Comment Jul 21, 2026 9:33pm
workflow-web Ready Ready Preview, Comment Jul 21, 2026 9:33pm

@changeset-bot

changeset-bot Bot 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
Name Type
@workflow/world-vercel Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/core Patch
@workflow/web Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/nuxt Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/vitest Patch
workflow Patch
@workflow/world-testing Patch
@workflow/web-shared Patch

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-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 1455 0 239 1694
✅ 💻 Local Development 1621 0 227 1848
✅ 📦 Local Production 1621 0 227 1848
✅ 🐘 Local Postgres 1621 0 227 1848
✅ 🪟 Windows 154 0 0 154
✅ 📋 Other 1020 0 212 1232
✅ vercel-multi-region 27 0 0 27
Total 7519 0 1132 8651

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 126 0 28
✅ example 126 0 28
✅ express 126 0 28
✅ fastify 126 0 28
✅ hono 126 0 28
✅ nextjs-turbopack 151 0 3
✅ nextjs-webpack 151 0 3
✅ nitro 126 0 28
✅ nuxt 126 0 28
✅ sveltekit 145 0 9
✅ vite 126 0 28
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 154 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 26
✅ e2e-local-dev-tanstack-start- 128 0 26
✅ e2e-local-postgres-nest-stable 128 0 26
✅ e2e-local-postgres-tanstack-start- 128 0 26
✅ e2e-local-prod-nest-stable 128 0 26
✅ e2e-local-prod-tanstack-start- 128 0 26
✅ e2e-vercel-prod-nest 126 0 28
✅ e2e-vercel-prod-tanstack-start 126 0 28
✅ vercel-multi-region
App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@github-actions

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

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 156 (+41%) 🔻 384 🔴 (+72%) 🔻 679 🔴 (+67%) 🔻 3201 🔴 (+509%) 🔻 30
TTFS stream 151 (+11%) 412 🔴 (+43%) 🔻 2524 🔴 (+597%) 🔻 3641 🔴 (+353%) 🔻 30
TTFS hook + stream 290 (-17%) 💚 522 🔴 (+2.8%) 647 🔴 (+12%) 3042 🔴 (+393%) 🔻 30
STSO 1020 steps (1-20) 226 (+32%) 🔻 363 🔴 (+37%) 🔻 467 🔴 (+45%) 🔻 675 🔴 (+85%) 🔻 19
STSO 1020 steps (101-120) 287 (+39%) 🔻 426 🔴 (+40%) 🔻 566 🔴 (+65%) 🔻 632 🔴 (+34%) 🔻 19
STSO 1020 steps (1001-1020) 446 (-24%) 💚 542 🔴 (-24%) 💚 629 🔴 (-19%) 💚 744 🔴 (-9.3%) 19
WO 1020 steps 551510 (+4.6%) 551510 (+4.6%) 551510 (+4.6%) 551510 (+4.6%) 1
SL stream latency 92 (+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 main Jul 21, 2026
173 of 177 checks passed
@karthikscale3
karthikscale3 deleted the kk/stream-close-retry branch July 21, 2026 23:01
@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
Sign up for free to 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