[core] Handle 429 and 500 errors from worlds in runtime - #966

Merged
VaguelySerious merged 12 commits into
mainfrom
peter/429-core
Feb 9, 2026
Merged

[core] Handle 429 and 500 errors from worlds in runtime#966
VaguelySerious merged 12 commits into
mainfrom
peter/429-core

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Feb 6, 2026

Copy link
Copy Markdown
Member

Uses retry-after header for 429 when provided.
500s are limited to 3x retry, doing exponential backoff.

Review with white space off for sanity.

Claude's suggestion for e2e tests, which I think we might do separately. I think we should make some sort of testbench for world errors and how the runtime reacts.

 3. E2E with a fault-injection endpoint (moderate effort, very targeted)
Add a test-only endpoint to the workbench app (or a middleware) that can be told to return 429/500 for the next N requests. Something like:
POST /e2e/inject-fault { status: 429, retryAfter: 3, count: 2 }
Then the e2e test would:
1. Inject a fault (e.g. "next 2 requests to workflow handler return 429 with Retry-After: 3")
2. Start a workflow
3. Assert it completes successfully despite the transient errors
4. Optionally check logs/traces for the retry events
This is the most surgical approach but requires adding test-only infrastructure. The world-local queue already makes HTTP requests to the app endpoints, so you could intercept at that
layer.

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@changeset-bot

changeset-botBot commented Feb 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9f75d33

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

This PR includes changesets to release 19 packages
NameType
@workflow/errorsPatch
@workflow/worldPatch
@workflow/world-vercelPatch
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
workflowPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/web-sharedPatch
@workflow/world-testingPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

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

@vercel

vercelBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production490038528
✅ 💻 Local Development418062480
✅ 📦 Local Production418062480
✅ 🐘 Local Postgres418062480
✅ 🪟 Windows450348
❌ 🌍 Community Worlds105429156
✅ 📋 Other123021144
Total2017422572316

❌ Failed Tests

🌍 Community Worlds (42 failed)

mongodb (1 failed):

  • webhookWorkflow

turso (41 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro4404
✅ example4404
✅ express4404
✅ fastify4404
✅ hono4404
✅ nextjs-turbopack4701
✅ nextjs-webpack4701
✅ nitro4404
✅ nuxt4404
✅ sveltekit4404
✅ vite4404
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack4503
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
❌ mongodb4413
✅ redis-dev300
✅ redis4503
✅ starter-dev300
✅ turso-dev300
❌ turso4413
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4107
✅ e2e-local-postgres-nest-stable4107
✅ e2e-local-prod-nest-stable4107

📋 View full workflow run

@github-actions

github-actionsBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro0.032s (+22.9% 🔺)1.005s (~)0.973s101.00x
💻 LocalExpress0.032s (-2.7%)1.005s (~)0.973s101.02x
💻 LocalNext.js (Turbopack)0.046s1.005s0.959s101.44x
🌐 RedisNext.js (Turbopack)0.051s1.005s0.953s101.62x
🐘 PostgresNitro0.106s (+13.5% 🔺)1.009s (~)0.903s103.36x
🌐 MongoDBNext.js (Turbopack)0.110s1.007s0.897s103.48x
🐘 PostgresExpress0.194s (-45.0% 🟢)1.021s (-2.1%)0.827s106.12x
🐘 PostgresNext.js (Turbopack)0.444s1.009s0.565s1014.01x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.784s (-7.5% 🟢)2.468s (-4.7%)1.684s101.00x
▲ VercelNext.js (Turbopack)0.786s (-14.1% 🟢)2.385s (-1.5%)1.599s101.00x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.105s2.006s0.901s101.00x
💻 LocalNitro1.105s (+2.8%)2.006s (~)0.901s101.00x
💻 LocalExpress1.108s (~)2.006s (~)0.898s101.00x
🌐 RedisNext.js (Turbopack)1.110s2.006s0.896s101.00x
🌐 MongoDBNext.js (Turbopack)1.300s2.007s0.707s101.18x
🐘 PostgresNext.js (Turbopack)1.907s2.113s0.206s101.73x
🐘 PostgresExpress2.460s (+10.6% 🔺)3.014s (~)0.554s102.23x
🐘 PostgresNitro2.500s (+27.1% 🔺)3.013s (+36.1% 🔺)0.513s102.26x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.957s (+12.5% 🔺)3.946s (+7.1% 🔺)0.988s101.00x
▲ VercelExpress3.215s (+22.0% 🔺)4.569s (+21.6% 🔺)1.354s101.09x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.722s11.022s0.300s31.00x
💻 LocalNext.js (Turbopack)10.745s11.023s0.278s31.00x
💻 LocalNitro10.838s (+2.4%)11.022s (~)0.184s31.01x
💻 LocalExpress10.839s (~)11.024s (~)0.185s31.01x
🌐 MongoDBNext.js (Turbopack)12.349s13.025s0.677s31.15x
🐘 PostgresNext.js (Turbopack)15.512s16.049s0.537s21.45x
🐘 PostgresExpress20.303s (~)21.061s (~)0.758s21.89x
🐘 PostgresNitro20.372s (+32.8% 🔺)21.053s (+31.2% 🔺)0.682s21.90x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express18.895s (-5.9% 🟢)20.661s (-1.5%)1.766s21.00x
▲ VercelNext.js (Turbopack)21.076s (+8.2% 🔺)22.174s (+8.3% 🔺)1.098s21.12x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.866s27.050s0.185s31.00x
💻 LocalNext.js (Turbopack)27.305s28.051s0.746s31.02x
💻 LocalNitro27.454s (+2.6%)28.049s (+3.7%)0.596s31.02x
💻 LocalExpress27.557s (~)28.052s (~)0.495s31.03x
🌐 MongoDBNext.js (Turbopack)30.556s31.046s0.490s21.14x
🐘 PostgresNitro50.218s (+21.3% 🔺)51.134s (+21.4% 🔺)0.915s21.87x
🐘 PostgresExpress50.303s (~)50.630s (-1.0%)0.327s21.87x
🐘 PostgresNext.js (Turbopack)50.312s50.623s0.312s21.87x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express48.126s (-3.0%)48.969s (-5.3% 🟢)0.843s21.00x
▲ VercelNext.js (Turbopack)49.886s (-4.5%)51.786s (-3.7%)1.900s21.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)54.374s55.101s0.726s21.00x
💻 LocalNext.js (Turbopack)56.919s57.103s0.184s21.05x
💻 LocalNitro57.120s (+2.8%)58.098s (+3.6%)0.978s21.05x
💻 LocalExpress57.342s (~)58.106s (~)0.764s21.05x
🌐 MongoDBNext.js (Turbopack)61.157s61.592s0.435s21.12x
🐘 PostgresNext.js (Turbopack)91.740s92.206s0.466s11.69x
🐘 PostgresExpress100.159s (~)100.227s (-1.0%)0.068s11.84x
🐘 PostgresNitro100.479s (+26.0% 🔺)101.227s (+26.2% 🔺)0.748s11.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express101.457s (-0.6%)102.618s (-0.8%)1.161s11.00x
▲ VercelNext.js (Turbopack)104.877s (+1.8%)105.825s (+2.1%)0.948s11.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.254s2.006s0.753s151.00x
💻 LocalNitro1.405s (+4.5%)2.006s (~)0.601s151.12x
💻 LocalExpress1.416s (~)2.005s (~)0.589s151.13x
💻 LocalNext.js (Turbopack)1.432s2.005s0.573s151.14x
🐘 PostgresNext.js (Turbopack)2.095s2.474s0.379s131.67x
🌐 MongoDBNext.js (Turbopack)2.164s3.008s0.844s101.73x
🐘 PostgresExpress2.194s (-6.6% 🟢)3.014s (~)0.820s101.75x
🐘 PostgresNitro2.485s (+40.2% 🔺)3.014s (+45.0% 🔺)0.529s101.98x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.728s (+1.1%)3.889s (+4.9%)1.161s81.00x
▲ VercelExpress3.000s (+10.3% 🔺)4.204s (+9.7% 🔺)1.204s81.10x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)2.485s3.008s0.523s101.00x
💻 LocalNitro2.544s (+13.2% 🔺)3.007s (~)0.463s101.02x
💻 LocalExpress2.619s (+2.4%)3.007s (~)0.388s101.05x
💻 LocalNext.js (Turbopack)2.642s3.007s0.364s101.06x
🌐 MongoDBNext.js (Turbopack)4.767s5.178s0.411s61.92x
🐘 PostgresExpress8.884s (-0.5%)9.546s (+2.9%)0.661s43.58x
🐘 PostgresNitro9.960s (-10.4% 🟢)10.697s (-8.6% 🟢)0.737s34.01x
🐘 PostgresNext.js (Turbopack)12.648s13.037s0.389s35.09x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.552s (+9.2% 🔺)5.099s (+14.9% 🔺)1.547s61.00x
▲ VercelExpress3.700s (+17.3% 🔺)5.005s (+20.3% 🔺)1.305s71.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)4.067s4.725s0.658s71.00x
💻 LocalNitro7.071s (+19.5% 🔺)7.767s (+21.1% 🔺)0.696s41.74x
💻 LocalNext.js (Turbopack)7.627s8.016s0.390s41.88x
💻 LocalExpress7.708s (+5.2% 🔺)8.273s (+3.2%)0.565s41.90x
🌐 MongoDBNext.js (Turbopack)9.877s10.352s0.475s32.43x
🐘 PostgresNitro50.050s (-4.4%)50.119s (-5.7% 🟢)0.069s112.31x
🐘 PostgresExpress51.197s (+8.8% 🔺)52.128s (+8.4% 🔺)0.931s112.59x
🐘 PostgresNext.js (Turbopack)55.317s56.128s0.811s113.60x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.682s (-1.3%)5.309s (+5.1% 🔺)1.626s61.00x
▲ VercelExpress4.333s (+22.4% 🔺)5.927s (+17.6% 🔺)1.594s71.18x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.259s2.006s0.747s151.00x
💻 LocalNitro1.425s (+5.2% 🔺)2.004s (~)0.580s151.13x
💻 LocalExpress1.431s (~)2.006s (~)0.574s151.14x
💻 LocalNext.js (Turbopack)1.464s2.006s0.542s151.16x
🐘 PostgresNitro2.141s (+27.4% 🔺)2.831s (+36.3% 🔺)0.691s111.70x
🐘 PostgresExpress2.142s (+2.0%)2.741s (+5.6% 🔺)0.599s111.70x
🌐 MongoDBNext.js (Turbopack)2.169s3.008s0.839s101.72x
🐘 PostgresNext.js (Turbopack)2.308s2.741s0.432s111.83x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.631s (+1.9%)3.817s (-0.8%)1.186s81.00x
▲ VercelNext.js (Turbopack)2.777s (+2.8%)4.032s (+4.4%)1.254s81.06x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)2.493s3.008s0.515s101.00x
💻 LocalNitro2.635s (+9.4% 🔺)3.007s (~)0.372s101.06x
💻 LocalNext.js (Turbopack)2.722s3.008s0.285s101.09x
💻 LocalExpress2.837s (+3.1%)3.008s (~)0.170s101.14x
🌐 MongoDBNext.js (Turbopack)4.725s5.176s0.451s61.89x
🐘 PostgresExpress11.149s (+2.8%)11.698s (+2.9%)0.548s34.47x
🐘 PostgresNitro12.296s (+14.2% 🔺)13.039s (+14.7% 🔺)0.743s34.93x
🐘 PostgresNext.js (Turbopack)12.730s13.368s0.638s35.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.981s (-6.6% 🟢)4.089s (-0.8%)1.108s81.00x
▲ VercelNext.js (Turbopack)3.066s (-10.0% 🟢)3.923s (-15.5% 🟢)0.857s81.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)4.079s4.725s0.646s71.00x
💻 LocalNitro7.739s (+16.5% 🔺)8.016s (+14.3% 🔺)0.277s41.90x
💻 LocalNext.js (Turbopack)8.017s8.518s0.501s41.97x
💻 LocalExpress8.163s (+4.8%)9.023s (+12.5% 🔺)0.860s42.00x
🌐 MongoDBNext.js (Turbopack)9.734s10.348s0.614s32.39x
🐘 PostgresNitro52.679s (+7.4% 🔺)53.124s (+8.1% 🔺)0.445s112.91x
🐘 PostgresNext.js (Turbopack)54.508s55.127s0.619s113.36x
🐘 PostgresExpress54.742s (+4.8%)55.132s (+3.8%)0.390s113.42x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.188s (-6.3% 🟢)4.374s (-13.7% 🟢)1.186s71.00x
▲ VercelNext.js (Turbopack)3.623s (-14.2% 🟢)5.046s (-9.5% 🟢)1.422s61.14x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.147s1.001s0.011s1.016s0.869s101.00x
🌐 RedisNext.js (Turbopack)0.148s1.000s0.001s1.007s0.859s101.00x
💻 LocalNitro0.173s (+55.5% 🔺)1.002s (~)0.010s (+14.6% 🔺)1.015s (~)0.842s101.17x
💻 LocalExpress0.175s (~)1.002s (~)0.011s (+9.1% 🔺)1.016s (~)0.841s101.19x
🌐 MongoDBNext.js (Turbopack)0.496s0.951s0.001s1.009s0.513s103.37x
🐘 PostgresNext.js (Turbopack)1.459s1.650s0.001s2.012s0.553s109.90x
🐘 PostgresNitro2.144s (+68.7% 🔺)2.900s (+63.8% 🔺)0.001s (~)3.015s (+49.7% 🔺)0.871s1014.54x
🐘 PostgresExpress2.271s (+4.8%)2.771s (-3.6%)0.001s (-16.7% 🟢)3.015s (~)0.744s1015.41x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.396s (-6.6% 🟢)3.058s (-8.5% 🟢)0.167s (+8.2% 🔺)3.971s (-6.6% 🟢)1.575s101.00x
▲ VercelNext.js (Turbopack)2.609s (+8.1% 🔺)2.867s (-6.0% 🟢)0.198s (+87.7% 🔺)3.836s (-0.9%)1.227s101.09x
▲ VercelNitro⚠️missing-----

🔍 Observability: Express | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNitro7/12
🐘 PostgresNitro5/12
▲ VercelExpress8/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express💻 Local10/12
Next.js (Turbopack)🌐 Redis7/12
Nitro💻 Local12/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Starter: Community world (local development)
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

- Propagate Retry-After header as WorkflowAPIError.retryAfter on 429 responses
- Add withThrottleRetry wrapper for both workflow and step handlers
- Re-enqueue workflows on 5xx errors with exponential backoff (5s, 30s, 120min)
- Track serverErrorRetryCount in queue payload for retry budgeting
- Expose delaySeconds on QueueOptions interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
);
// Short wait: sleep in-process, then retry once
await new Promise((resolve) =>
setTimeout(resolve, retryAfterSeconds * 1000)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we account for function execution time limits specifically in the case of Vercel world? If the serverless fuction is already close to the end of it's limit and the workflow server throws a 429, adding a 10 sec sleep could potentially exceed the function execution limit and the function could get SIGKILLd midway.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

The workflow layer should never take much more than a few seconds, so I think it's highly unlikely that we'd run into timeouts, so I'm not too worried about this, but technically a concern

@karthikscale3

Copy link
Copy Markdown
Contributor

Rest of the code looks good except the one concern I have around function execution limits in the case of vercel world.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this looks solid — the 429 throttle retry and 5xx backoff are clean additions. The withThrottleRetry wrapper with the "safe to retry because 429 is pre-processing" invariant is a nice design. A few things to consider:

world-local doesn't support delaySeconds — The QueueOptions interface was updated and world-vercel passes it through, but world-local/src/queue.ts silently ignores opts?.delaySeconds. If a 5xx retry fires in local dev, the message will be re-enqueued but delivered immediately instead of after the intended backoff. Not a blocker since 5xx from world-local is uncommon, but could cause confusing behavior during local fault testing (which the PR description mentions wanting to add).

Step handler diff is mostly re-indentation — confirmed the actual logic changes are just wrapping in withThrottleRetry. The step handler does NOT get 5xx retry (only 429), which makes sense since the queue's built-in redelivery + step_started idempotency handles that.

@pranaygp

Copy link
Copy Markdown
Contributor

Inline review notes

Since GitHub's review API is having issues, posting inline comments here:


packages/core/src/runtime.ts:291delaySecondSteps = [5, 30, 7200]

The third backoff step is 7200s (2 hours). Is the expectation that persistent 5xx would trigger an alert/incident on the server side well before this fires? A brief comment explaining the rationale for these specific values would help future readers.


packages/core/src/runtime.ts:290-307serverErrorRetryCount lifecycle

The serverErrorRetryCount resets on each new workflow invocation (since the next queueMessage after a successful step won't include it). This means each invocation gets a fresh retry budget. That seems right for transient 5xx, but if the server is persistently down, each invocation will independently burn through all 3 retries before failing. Just want to confirm this is the intended behavior vs. tracking the count on the run itself.


packages/core/src/runtime/helpers.ts:425withThrottleRetry short-wait path

If the first in-process retry succeeds, great. But if it gets a different error (not 429), that error is thrown — which means the handler fails entirely rather than deferring to queue. This seems correct since a non-429 error should propagate, just want to confirm the intent.


packages/world-vercel/src/utils.ts:298Retry-After parsing

nit: Retry-After can also be an HTTP-date (RFC 9110). This only handles the delay-seconds format. Fine if you control the server, but worth a comment noting the assumption.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this is clean and well-structured. The two-tier 429 strategy (in-process for short waits, defer to queue for long waits) is nice. A few questions and concerns below.

*
* Safe to retry the entire handler because 429 is sent from server middleware
* before the request is processed — no server state has changed.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comment says "Safe to retry the entire handler because 429 is sent from server middleware before the request is processed — no server state has changed."

This holds if the 429 always hits on the first API call in the handler. But the handler makes multiple world API calls sequentially (e.g. runs.getevents.create(run_started) → replay → events.create(run_completed)). If a later call gets 429'd, the retry re-executes everything from the top.

For the workflow handler this is probably fine since replay is deterministic and events are idempotent.

For the step handler this is more concerning — the retry would re-execute user step code, which may not be idempotent. Is the assumption that the workflow-server's rate limiting middleware rejects at the connection level (so all calls in one handler invocation either succeed or all fail)? If so, worth documenting that assumption.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch, fixed

Comment threadpackages/core/src/runtime.ts Outdated
err.status >= 500
) {
const retryCount = serverErrorRetryCount ?? 0;
const delaySecondSteps = [5, 30, 7200]; // 5s, 30s, 120min

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The third step is 7200 seconds = 2 hours. Is that intentional? That's a very long time for a workflow to be stalled on a server error. If the 500 was truly transient, 2 hours feels excessive. If it's a sustained outage, you'd probably want to fail rather than retry after 2h when the server might have moved on.

Maybe something like [5, 30, 120] (5s, 30s, 2min) would be more practical?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Changed to [5, 30, 120]. Realistically if we keep getting 500s, it'd be nice to re-try the run much later so we have a change to incident-mitigate, but I think it's fine to fail runs instead too

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

cool. this was fully AI generated review btw. idk where it came up with 5s, 30, 120 - but I do agree i we should have some shorter ones before getting to 7200 anyway

}

const maxRetries = stepFn.maxRetries ?? DEFAULT_STEP_MAX_RETRIES;
return await withThrottleRetry(async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The step handler gets withThrottleRetry for 429s but no equivalent 5xx retry logic like the workflow handler has. If a world API call (e.g. events.create for step_started or step_completed) returns a 5xx, it'll be caught by the step's error handling and burn a step retry attempt — re-executing potentially expensive user code for what was a transient infrastructure error.

Is this intentional for this PR's scope, or should step handlers also get 5xx backoff retry?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

500s are handled via regular step re-try. 429s are only special-cased because they allow the step-retry mechanism to use the retry-after header instead of guessing at the retry

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

right but the comment is about not burning a full step attempt for transient 5xx. doesn't need to be addressed in this PR necessarily but good to think about. happy to move in any direction for now since they're all better than nothing and then come back to this in the future with real experience

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Makes sense that steps rely on their existing retry mechanism. One concern though: with withThrottleRetry removed, a 429 from the world API during step execution (e.g. on step_started or step_completed) will now burn a step retry attempt — potentially exhausting maxRetries without the user's code ever being at fault.

Ideally 429s should be retried transparently without consuming an attempt. A targeted retry around just the world API calls (not wrapping the user code execution) could thread that needle. Worth tracking as a follow-up.

idempotencyKey?: string;
headers?: Record<string, string>;
/** Delay message delivery by this many seconds */
delaySeconds?: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good that this was promoted from the vercel-specific type to the shared interface. Note that world-local currently ignores delaySeconds entirely — so 5xx retries in local dev will fire immediately instead of with backoff. Not a blocker but worth a follow-up or at minimum a comment.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Seems fine to ignore for local world, since there are no 429s

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

although we probably do actually want to support delaySeconds in local world anyway as we start using this option in queue more often in the future for more use cases. even if it's not implemented in this PR, I think we should leave an explicit comment that local world ignores this since it's a nuance. We should later have an e2e test that checks for this behaviour and would fail on local world without proper queue delaySeconds implementation (cc @TooTallNate )

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/src/runtime/step-handler.ts Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good! The removal of withThrottleRetry from the step handler was the right fix for the re-execution safety concern.

One thing worth tracking as a follow-up: with withThrottleRetry removed from steps, a 429 from the world API during step execution (e.g. on step_started or step_completed) will now burn a step retry attempt. This means a transient throttle from the server could exhaust a step's maxRetries without the user's code ever being at fault. Ideally 429s should be retried transparently without consuming an attempt — maybe a targeted retry around just the world API calls (not the user code execution) would thread that needle.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: [core] Handle 429 and 500 errors from worlds in runtime

Overall this is a solid approach to handling transient server errors. The 429 throttle retry with short/long path split is well-designed, and the 500 retry via re-enqueue with exponential backoff is a safe pattern. A few items to address below.

);
runtimeLogger.warn(
'Throttled again on retry, deferring to queue',
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redundant check: this is already inside if (WorkflowAPIError.is(err)), so the second WorkflowAPIError.is(err) on this line is always true. Should just be if (err.status === 429).

step = startResult.step;
} catch (err) {
if (WorkflowAPIError.is(err)) {
if (WorkflowAPIError.is(err) && err.status === 429) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The step handler has 429 handling here inside the step_started catch block, but unlike the workflow handler, it does not use withThrottleRetry and does not have the short-wait in-process retry path. This means a brief 429 (e.g., retryAfter=2s) will always defer to the queue rather than sleeping in-process. Is that intentional? If steps should also get the in-process retry for short waits, consider wrapping with withThrottleRetry similarly to the workflow handler.

if (retryAfterSeconds < 10) {
runtimeLogger.warn(
'Throttled by workflow-server (429), retrying in-process',
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: if retryAfter is undefined (header missing), this defaults to 1 second. For 429 responses without a Retry-After header, 1 second might be too aggressive. A slightly higher default (e.g., 3-5s) would be more conservative and reduce the chance of hitting the server again immediately while it's under load.

workflowStartedAt = +workflowRun.startedAt;

// At this point, the workflow is "running" and `startedAt` should
// definitely be set.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The entire workflow handler body is now wrapped in withThrottleRetry. This means if a 429 is thrown by any world call during workflow execution (e.g., world.runs.get, world.events.create, runWorkflow), the entire handler will be retried from scratch.

  1. If run_started event was already created successfully and then a later call throws 429, the retry will call world.runs.get again and find the run already running, which is handled correctly.
  2. However, if run_completed event creation throws 429, the retry would re-run the entire workflow (replay + execution). The workflow replay should be deterministic, but this could be expensive. Worth documenting this tradeoff in a comment.

});
// Use the run entity from the event response (no extra get call needed)
if (!result.run) {
if (workflowRun.status === 'pending') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The 500 retry re-enqueues with a new requestedAt and incremented serverErrorRetryCount. The traceCarrier is serialized fresh via serializeTraceCarrier(). If the trace context matters for correlating retries, this creates a new trace for each retry attempt. Consider whether preserving the original traceContext from the message would be preferable for observability (linking all retry attempts to the same parent trace).

WorldParseFormat,
PeerService,
RpcSystem,
RpcService,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note: the HTTP Retry-After header can also be an HTTP-date (e.g., Wed, 21 Oct 2015 07:28:00 GMT). The parseInt approach correctly returns NaN for date values, falling back to undefined. This is fine since the workflow-server likely only sends numeric values, but a comment noting this would be helpful.

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/src/runtime/helpers.ts Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
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.

3 participants

@VaguelySerious@karthikscale3@pranaygp
, '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

[core] Handle 429 and 500 errors from worlds in runtime - #966

Merged
VaguelySerious merged 12 commits into
mainfrom
peter/429-core
Feb 9, 2026
Merged

[core] Handle 429 and 500 errors from worlds in runtime#966
VaguelySerious merged 12 commits into
mainfrom
peter/429-core

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Feb 6, 2026

Copy link
Copy Markdown
Member

Uses retry-after header for 429 when provided.
500s are limited to 3x retry, doing exponential backoff.

Review with white space off for sanity.

Claude's suggestion for e2e tests, which I think we might do separately. I think we should make some sort of testbench for world errors and how the runtime reacts.

 3. E2E with a fault-injection endpoint (moderate effort, very targeted)
Add a test-only endpoint to the workbench app (or a middleware) that can be told to return 429/500 for the next N requests. Something like:
POST /e2e/inject-fault { status: 429, retryAfter: 3, count: 2 }
Then the e2e test would:
1. Inject a fault (e.g. "next 2 requests to workflow handler return 429 with Retry-After: 3")
2. Start a workflow
3. Assert it completes successfully despite the transient errors
4. Optionally check logs/traces for the retry events
This is the most surgical approach but requires adding test-only infrastructure. The world-local queue already makes HTTP requests to the app endpoints, so you could intercept at that
layer.

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@changeset-bot

changeset-botBot commented Feb 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9f75d33

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

This PR includes changesets to release 19 packages
NameType
@workflow/errorsPatch
@workflow/worldPatch
@workflow/world-vercelPatch
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
workflowPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/web-sharedPatch
@workflow/world-testingPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

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

@vercel

vercelBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production490038528
✅ 💻 Local Development418062480
✅ 📦 Local Production418062480
✅ 🐘 Local Postgres418062480
✅ 🪟 Windows450348
❌ 🌍 Community Worlds105429156
✅ 📋 Other123021144
Total2017422572316

❌ Failed Tests

🌍 Community Worlds (42 failed)

mongodb (1 failed):

  • webhookWorkflow

turso (41 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro4404
✅ example4404
✅ express4404
✅ fastify4404
✅ hono4404
✅ nextjs-turbopack4701
✅ nextjs-webpack4701
✅ nitro4404
✅ nuxt4404
✅ sveltekit4404
✅ vite4404
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack4503
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
❌ mongodb4413
✅ redis-dev300
✅ redis4503
✅ starter-dev300
✅ turso-dev300
❌ turso4413
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4107
✅ e2e-local-postgres-nest-stable4107
✅ e2e-local-prod-nest-stable4107

📋 View full workflow run

@github-actions

github-actionsBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro0.032s (+22.9% 🔺)1.005s (~)0.973s101.00x
💻 LocalExpress0.032s (-2.7%)1.005s (~)0.973s101.02x
💻 LocalNext.js (Turbopack)0.046s1.005s0.959s101.44x
🌐 RedisNext.js (Turbopack)0.051s1.005s0.953s101.62x
🐘 PostgresNitro0.106s (+13.5% 🔺)1.009s (~)0.903s103.36x
🌐 MongoDBNext.js (Turbopack)0.110s1.007s0.897s103.48x
🐘 PostgresExpress0.194s (-45.0% 🟢)1.021s (-2.1%)0.827s106.12x
🐘 PostgresNext.js (Turbopack)0.444s1.009s0.565s1014.01x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.784s (-7.5% 🟢)2.468s (-4.7%)1.684s101.00x
▲ VercelNext.js (Turbopack)0.786s (-14.1% 🟢)2.385s (-1.5%)1.599s101.00x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.105s2.006s0.901s101.00x
💻 LocalNitro1.105s (+2.8%)2.006s (~)0.901s101.00x
💻 LocalExpress1.108s (~)2.006s (~)0.898s101.00x
🌐 RedisNext.js (Turbopack)1.110s2.006s0.896s101.00x
🌐 MongoDBNext.js (Turbopack)1.300s2.007s0.707s101.18x
🐘 PostgresNext.js (Turbopack)1.907s2.113s0.206s101.73x
🐘 PostgresExpress2.460s (+10.6% 🔺)3.014s (~)0.554s102.23x
🐘 PostgresNitro2.500s (+27.1% 🔺)3.013s (+36.1% 🔺)0.513s102.26x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.957s (+12.5% 🔺)3.946s (+7.1% 🔺)0.988s101.00x
▲ VercelExpress3.215s (+22.0% 🔺)4.569s (+21.6% 🔺)1.354s101.09x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.722s11.022s0.300s31.00x
💻 LocalNext.js (Turbopack)10.745s11.023s0.278s31.00x
💻 LocalNitro10.838s (+2.4%)11.022s (~)0.184s31.01x
💻 LocalExpress10.839s (~)11.024s (~)0.185s31.01x
🌐 MongoDBNext.js (Turbopack)12.349s13.025s0.677s31.15x
🐘 PostgresNext.js (Turbopack)15.512s16.049s0.537s21.45x
🐘 PostgresExpress20.303s (~)21.061s (~)0.758s21.89x
🐘 PostgresNitro20.372s (+32.8% 🔺)21.053s (+31.2% 🔺)0.682s21.90x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express18.895s (-5.9% 🟢)20.661s (-1.5%)1.766s21.00x
▲ VercelNext.js (Turbopack)21.076s (+8.2% 🔺)22.174s (+8.3% 🔺)1.098s21.12x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.866s27.050s0.185s31.00x
💻 LocalNext.js (Turbopack)27.305s28.051s0.746s31.02x
💻 LocalNitro27.454s (+2.6%)28.049s (+3.7%)0.596s31.02x
💻 LocalExpress27.557s (~)28.052s (~)0.495s31.03x
🌐 MongoDBNext.js (Turbopack)30.556s31.046s0.490s21.14x
🐘 PostgresNitro50.218s (+21.3% 🔺)51.134s (+21.4% 🔺)0.915s21.87x
🐘 PostgresExpress50.303s (~)50.630s (-1.0%)0.327s21.87x
🐘 PostgresNext.js (Turbopack)50.312s50.623s0.312s21.87x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express48.126s (-3.0%)48.969s (-5.3% 🟢)0.843s21.00x
▲ VercelNext.js (Turbopack)49.886s (-4.5%)51.786s (-3.7%)1.900s21.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)54.374s55.101s0.726s21.00x
💻 LocalNext.js (Turbopack)56.919s57.103s0.184s21.05x
💻 LocalNitro57.120s (+2.8%)58.098s (+3.6%)0.978s21.05x
💻 LocalExpress57.342s (~)58.106s (~)0.764s21.05x
🌐 MongoDBNext.js (Turbopack)61.157s61.592s0.435s21.12x
🐘 PostgresNext.js (Turbopack)91.740s92.206s0.466s11.69x
🐘 PostgresExpress100.159s (~)100.227s (-1.0%)0.068s11.84x
🐘 PostgresNitro100.479s (+26.0% 🔺)101.227s (+26.2% 🔺)0.748s11.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express101.457s (-0.6%)102.618s (-0.8%)1.161s11.00x
▲ VercelNext.js (Turbopack)104.877s (+1.8%)105.825s (+2.1%)0.948s11.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.254s2.006s0.753s151.00x
💻 LocalNitro1.405s (+4.5%)2.006s (~)0.601s151.12x
💻 LocalExpress1.416s (~)2.005s (~)0.589s151.13x
💻 LocalNext.js (Turbopack)1.432s2.005s0.573s151.14x
🐘 PostgresNext.js (Turbopack)2.095s2.474s0.379s131.67x
🌐 MongoDBNext.js (Turbopack)2.164s3.008s0.844s101.73x
🐘 PostgresExpress2.194s (-6.6% 🟢)3.014s (~)0.820s101.75x
🐘 PostgresNitro2.485s (+40.2% 🔺)3.014s (+45.0% 🔺)0.529s101.98x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.728s (+1.1%)3.889s (+4.9%)1.161s81.00x
▲ VercelExpress3.000s (+10.3% 🔺)4.204s (+9.7% 🔺)1.204s81.10x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)2.485s3.008s0.523s101.00x
💻 LocalNitro2.544s (+13.2% 🔺)3.007s (~)0.463s101.02x
💻 LocalExpress2.619s (+2.4%)3.007s (~)0.388s101.05x
💻 LocalNext.js (Turbopack)2.642s3.007s0.364s101.06x
🌐 MongoDBNext.js (Turbopack)4.767s5.178s0.411s61.92x
🐘 PostgresExpress8.884s (-0.5%)9.546s (+2.9%)0.661s43.58x
🐘 PostgresNitro9.960s (-10.4% 🟢)10.697s (-8.6% 🟢)0.737s34.01x
🐘 PostgresNext.js (Turbopack)12.648s13.037s0.389s35.09x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.552s (+9.2% 🔺)5.099s (+14.9% 🔺)1.547s61.00x
▲ VercelExpress3.700s (+17.3% 🔺)5.005s (+20.3% 🔺)1.305s71.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)4.067s4.725s0.658s71.00x
💻 LocalNitro7.071s (+19.5% 🔺)7.767s (+21.1% 🔺)0.696s41.74x
💻 LocalNext.js (Turbopack)7.627s8.016s0.390s41.88x
💻 LocalExpress7.708s (+5.2% 🔺)8.273s (+3.2%)0.565s41.90x
🌐 MongoDBNext.js (Turbopack)9.877s10.352s0.475s32.43x
🐘 PostgresNitro50.050s (-4.4%)50.119s (-5.7% 🟢)0.069s112.31x
🐘 PostgresExpress51.197s (+8.8% 🔺)52.128s (+8.4% 🔺)0.931s112.59x
🐘 PostgresNext.js (Turbopack)55.317s56.128s0.811s113.60x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.682s (-1.3%)5.309s (+5.1% 🔺)1.626s61.00x
▲ VercelExpress4.333s (+22.4% 🔺)5.927s (+17.6% 🔺)1.594s71.18x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.259s2.006s0.747s151.00x
💻 LocalNitro1.425s (+5.2% 🔺)2.004s (~)0.580s151.13x
💻 LocalExpress1.431s (~)2.006s (~)0.574s151.14x
💻 LocalNext.js (Turbopack)1.464s2.006s0.542s151.16x
🐘 PostgresNitro2.141s (+27.4% 🔺)2.831s (+36.3% 🔺)0.691s111.70x
🐘 PostgresExpress2.142s (+2.0%)2.741s (+5.6% 🔺)0.599s111.70x
🌐 MongoDBNext.js (Turbopack)2.169s3.008s0.839s101.72x
🐘 PostgresNext.js (Turbopack)2.308s2.741s0.432s111.83x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.631s (+1.9%)3.817s (-0.8%)1.186s81.00x
▲ VercelNext.js (Turbopack)2.777s (+2.8%)4.032s (+4.4%)1.254s81.06x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)2.493s3.008s0.515s101.00x
💻 LocalNitro2.635s (+9.4% 🔺)3.007s (~)0.372s101.06x
💻 LocalNext.js (Turbopack)2.722s3.008s0.285s101.09x
💻 LocalExpress2.837s (+3.1%)3.008s (~)0.170s101.14x
🌐 MongoDBNext.js (Turbopack)4.725s5.176s0.451s61.89x
🐘 PostgresExpress11.149s (+2.8%)11.698s (+2.9%)0.548s34.47x
🐘 PostgresNitro12.296s (+14.2% 🔺)13.039s (+14.7% 🔺)0.743s34.93x
🐘 PostgresNext.js (Turbopack)12.730s13.368s0.638s35.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.981s (-6.6% 🟢)4.089s (-0.8%)1.108s81.00x
▲ VercelNext.js (Turbopack)3.066s (-10.0% 🟢)3.923s (-15.5% 🟢)0.857s81.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)4.079s4.725s0.646s71.00x
💻 LocalNitro7.739s (+16.5% 🔺)8.016s (+14.3% 🔺)0.277s41.90x
💻 LocalNext.js (Turbopack)8.017s8.518s0.501s41.97x
💻 LocalExpress8.163s (+4.8%)9.023s (+12.5% 🔺)0.860s42.00x
🌐 MongoDBNext.js (Turbopack)9.734s10.348s0.614s32.39x
🐘 PostgresNitro52.679s (+7.4% 🔺)53.124s (+8.1% 🔺)0.445s112.91x
🐘 PostgresNext.js (Turbopack)54.508s55.127s0.619s113.36x
🐘 PostgresExpress54.742s (+4.8%)55.132s (+3.8%)0.390s113.42x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.188s (-6.3% 🟢)4.374s (-13.7% 🟢)1.186s71.00x
▲ VercelNext.js (Turbopack)3.623s (-14.2% 🟢)5.046s (-9.5% 🟢)1.422s61.14x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.147s1.001s0.011s1.016s0.869s101.00x
🌐 RedisNext.js (Turbopack)0.148s1.000s0.001s1.007s0.859s101.00x
💻 LocalNitro0.173s (+55.5% 🔺)1.002s (~)0.010s (+14.6% 🔺)1.015s (~)0.842s101.17x
💻 LocalExpress0.175s (~)1.002s (~)0.011s (+9.1% 🔺)1.016s (~)0.841s101.19x
🌐 MongoDBNext.js (Turbopack)0.496s0.951s0.001s1.009s0.513s103.37x
🐘 PostgresNext.js (Turbopack)1.459s1.650s0.001s2.012s0.553s109.90x
🐘 PostgresNitro2.144s (+68.7% 🔺)2.900s (+63.8% 🔺)0.001s (~)3.015s (+49.7% 🔺)0.871s1014.54x
🐘 PostgresExpress2.271s (+4.8%)2.771s (-3.6%)0.001s (-16.7% 🟢)3.015s (~)0.744s1015.41x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.396s (-6.6% 🟢)3.058s (-8.5% 🟢)0.167s (+8.2% 🔺)3.971s (-6.6% 🟢)1.575s101.00x
▲ VercelNext.js (Turbopack)2.609s (+8.1% 🔺)2.867s (-6.0% 🟢)0.198s (+87.7% 🔺)3.836s (-0.9%)1.227s101.09x
▲ VercelNitro⚠️missing-----

🔍 Observability: Express | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNitro7/12
🐘 PostgresNitro5/12
▲ VercelExpress8/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express💻 Local10/12
Next.js (Turbopack)🌐 Redis7/12
Nitro💻 Local12/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Starter: Community world (local development)
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

- Propagate Retry-After header as WorkflowAPIError.retryAfter on 429 responses
- Add withThrottleRetry wrapper for both workflow and step handlers
- Re-enqueue workflows on 5xx errors with exponential backoff (5s, 30s, 120min)
- Track serverErrorRetryCount in queue payload for retry budgeting
- Expose delaySeconds on QueueOptions interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
);
// Short wait: sleep in-process, then retry once
await new Promise((resolve) =>
setTimeout(resolve, retryAfterSeconds * 1000)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we account for function execution time limits specifically in the case of Vercel world? If the serverless fuction is already close to the end of it's limit and the workflow server throws a 429, adding a 10 sec sleep could potentially exceed the function execution limit and the function could get SIGKILLd midway.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

The workflow layer should never take much more than a few seconds, so I think it's highly unlikely that we'd run into timeouts, so I'm not too worried about this, but technically a concern

@karthikscale3

Copy link
Copy Markdown
Contributor

Rest of the code looks good except the one concern I have around function execution limits in the case of vercel world.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this looks solid — the 429 throttle retry and 5xx backoff are clean additions. The withThrottleRetry wrapper with the "safe to retry because 429 is pre-processing" invariant is a nice design. A few things to consider:

world-local doesn't support delaySeconds — The QueueOptions interface was updated and world-vercel passes it through, but world-local/src/queue.ts silently ignores opts?.delaySeconds. If a 5xx retry fires in local dev, the message will be re-enqueued but delivered immediately instead of after the intended backoff. Not a blocker since 5xx from world-local is uncommon, but could cause confusing behavior during local fault testing (which the PR description mentions wanting to add).

Step handler diff is mostly re-indentation — confirmed the actual logic changes are just wrapping in withThrottleRetry. The step handler does NOT get 5xx retry (only 429), which makes sense since the queue's built-in redelivery + step_started idempotency handles that.

@pranaygp

Copy link
Copy Markdown
Contributor

Inline review notes

Since GitHub's review API is having issues, posting inline comments here:


packages/core/src/runtime.ts:291delaySecondSteps = [5, 30, 7200]

The third backoff step is 7200s (2 hours). Is the expectation that persistent 5xx would trigger an alert/incident on the server side well before this fires? A brief comment explaining the rationale for these specific values would help future readers.


packages/core/src/runtime.ts:290-307serverErrorRetryCount lifecycle

The serverErrorRetryCount resets on each new workflow invocation (since the next queueMessage after a successful step won't include it). This means each invocation gets a fresh retry budget. That seems right for transient 5xx, but if the server is persistently down, each invocation will independently burn through all 3 retries before failing. Just want to confirm this is the intended behavior vs. tracking the count on the run itself.


packages/core/src/runtime/helpers.ts:425withThrottleRetry short-wait path

If the first in-process retry succeeds, great. But if it gets a different error (not 429), that error is thrown — which means the handler fails entirely rather than deferring to queue. This seems correct since a non-429 error should propagate, just want to confirm the intent.


packages/world-vercel/src/utils.ts:298Retry-After parsing

nit: Retry-After can also be an HTTP-date (RFC 9110). This only handles the delay-seconds format. Fine if you control the server, but worth a comment noting the assumption.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this is clean and well-structured. The two-tier 429 strategy (in-process for short waits, defer to queue for long waits) is nice. A few questions and concerns below.

*
* Safe to retry the entire handler because 429 is sent from server middleware
* before the request is processed — no server state has changed.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comment says "Safe to retry the entire handler because 429 is sent from server middleware before the request is processed — no server state has changed."

This holds if the 429 always hits on the first API call in the handler. But the handler makes multiple world API calls sequentially (e.g. runs.getevents.create(run_started) → replay → events.create(run_completed)). If a later call gets 429'd, the retry re-executes everything from the top.

For the workflow handler this is probably fine since replay is deterministic and events are idempotent.

For the step handler this is more concerning — the retry would re-execute user step code, which may not be idempotent. Is the assumption that the workflow-server's rate limiting middleware rejects at the connection level (so all calls in one handler invocation either succeed or all fail)? If so, worth documenting that assumption.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch, fixed

Comment threadpackages/core/src/runtime.ts Outdated
err.status >= 500
) {
const retryCount = serverErrorRetryCount ?? 0;
const delaySecondSteps = [5, 30, 7200]; // 5s, 30s, 120min

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The third step is 7200 seconds = 2 hours. Is that intentional? That's a very long time for a workflow to be stalled on a server error. If the 500 was truly transient, 2 hours feels excessive. If it's a sustained outage, you'd probably want to fail rather than retry after 2h when the server might have moved on.

Maybe something like [5, 30, 120] (5s, 30s, 2min) would be more practical?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Changed to [5, 30, 120]. Realistically if we keep getting 500s, it'd be nice to re-try the run much later so we have a change to incident-mitigate, but I think it's fine to fail runs instead too

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

cool. this was fully AI generated review btw. idk where it came up with 5s, 30, 120 - but I do agree i we should have some shorter ones before getting to 7200 anyway

}

const maxRetries = stepFn.maxRetries ?? DEFAULT_STEP_MAX_RETRIES;
return await withThrottleRetry(async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The step handler gets withThrottleRetry for 429s but no equivalent 5xx retry logic like the workflow handler has. If a world API call (e.g. events.create for step_started or step_completed) returns a 5xx, it'll be caught by the step's error handling and burn a step retry attempt — re-executing potentially expensive user code for what was a transient infrastructure error.

Is this intentional for this PR's scope, or should step handlers also get 5xx backoff retry?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

500s are handled via regular step re-try. 429s are only special-cased because they allow the step-retry mechanism to use the retry-after header instead of guessing at the retry

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

right but the comment is about not burning a full step attempt for transient 5xx. doesn't need to be addressed in this PR necessarily but good to think about. happy to move in any direction for now since they're all better than nothing and then come back to this in the future with real experience

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Makes sense that steps rely on their existing retry mechanism. One concern though: with withThrottleRetry removed, a 429 from the world API during step execution (e.g. on step_started or step_completed) will now burn a step retry attempt — potentially exhausting maxRetries without the user's code ever being at fault.

Ideally 429s should be retried transparently without consuming an attempt. A targeted retry around just the world API calls (not wrapping the user code execution) could thread that needle. Worth tracking as a follow-up.

idempotencyKey?: string;
headers?: Record<string, string>;
/** Delay message delivery by this many seconds */
delaySeconds?: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good that this was promoted from the vercel-specific type to the shared interface. Note that world-local currently ignores delaySeconds entirely — so 5xx retries in local dev will fire immediately instead of with backoff. Not a blocker but worth a follow-up or at minimum a comment.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Seems fine to ignore for local world, since there are no 429s

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

although we probably do actually want to support delaySeconds in local world anyway as we start using this option in queue more often in the future for more use cases. even if it's not implemented in this PR, I think we should leave an explicit comment that local world ignores this since it's a nuance. We should later have an e2e test that checks for this behaviour and would fail on local world without proper queue delaySeconds implementation (cc @TooTallNate )

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/src/runtime/step-handler.ts Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good! The removal of withThrottleRetry from the step handler was the right fix for the re-execution safety concern.

One thing worth tracking as a follow-up: with withThrottleRetry removed from steps, a 429 from the world API during step execution (e.g. on step_started or step_completed) will now burn a step retry attempt. This means a transient throttle from the server could exhaust a step's maxRetries without the user's code ever being at fault. Ideally 429s should be retried transparently without consuming an attempt — maybe a targeted retry around just the world API calls (not the user code execution) would thread that needle.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: [core] Handle 429 and 500 errors from worlds in runtime

Overall this is a solid approach to handling transient server errors. The 429 throttle retry with short/long path split is well-designed, and the 500 retry via re-enqueue with exponential backoff is a safe pattern. A few items to address below.

);
runtimeLogger.warn(
'Throttled again on retry, deferring to queue',
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redundant check: this is already inside if (WorkflowAPIError.is(err)), so the second WorkflowAPIError.is(err) on this line is always true. Should just be if (err.status === 429).

step = startResult.step;
} catch (err) {
if (WorkflowAPIError.is(err)) {
if (WorkflowAPIError.is(err) && err.status === 429) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The step handler has 429 handling here inside the step_started catch block, but unlike the workflow handler, it does not use withThrottleRetry and does not have the short-wait in-process retry path. This means a brief 429 (e.g., retryAfter=2s) will always defer to the queue rather than sleeping in-process. Is that intentional? If steps should also get the in-process retry for short waits, consider wrapping with withThrottleRetry similarly to the workflow handler.

if (retryAfterSeconds < 10) {
runtimeLogger.warn(
'Throttled by workflow-server (429), retrying in-process',
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: if retryAfter is undefined (header missing), this defaults to 1 second. For 429 responses without a Retry-After header, 1 second might be too aggressive. A slightly higher default (e.g., 3-5s) would be more conservative and reduce the chance of hitting the server again immediately while it's under load.

workflowStartedAt = +workflowRun.startedAt;

// At this point, the workflow is "running" and `startedAt` should
// definitely be set.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The entire workflow handler body is now wrapped in withThrottleRetry. This means if a 429 is thrown by any world call during workflow execution (e.g., world.runs.get, world.events.create, runWorkflow), the entire handler will be retried from scratch.

  1. If run_started event was already created successfully and then a later call throws 429, the retry will call world.runs.get again and find the run already running, which is handled correctly.
  2. However, if run_completed event creation throws 429, the retry would re-run the entire workflow (replay + execution). The workflow replay should be deterministic, but this could be expensive. Worth documenting this tradeoff in a comment.

});
// Use the run entity from the event response (no extra get call needed)
if (!result.run) {
if (workflowRun.status === 'pending') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The 500 retry re-enqueues with a new requestedAt and incremented serverErrorRetryCount. The traceCarrier is serialized fresh via serializeTraceCarrier(). If the trace context matters for correlating retries, this creates a new trace for each retry attempt. Consider whether preserving the original traceContext from the message would be preferable for observability (linking all retry attempts to the same parent trace).

WorldParseFormat,
PeerService,
RpcSystem,
RpcService,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note: the HTTP Retry-After header can also be an HTTP-date (e.g., Wed, 21 Oct 2015 07:28:00 GMT). The parseInt approach correctly returns NaN for date values, falling back to undefined. This is fine since the workflow-server likely only sends numeric values, but a comment noting this would be helpful.

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/src/runtime/helpers.ts Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
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.

3 participants

@VaguelySerious@karthikscale3@pranaygp
, '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

[core] Handle 429 and 500 errors from worlds in runtime - #966

Merged
VaguelySerious merged 12 commits into
mainfrom
peter/429-core
Feb 9, 2026
Merged

[core] Handle 429 and 500 errors from worlds in runtime#966
VaguelySerious merged 12 commits into
mainfrom
peter/429-core

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Feb 6, 2026

Copy link
Copy Markdown
Member

Uses retry-after header for 429 when provided.
500s are limited to 3x retry, doing exponential backoff.

Review with white space off for sanity.

Claude's suggestion for e2e tests, which I think we might do separately. I think we should make some sort of testbench for world errors and how the runtime reacts.

 3. E2E with a fault-injection endpoint (moderate effort, very targeted)
Add a test-only endpoint to the workbench app (or a middleware) that can be told to return 429/500 for the next N requests. Something like:
POST /e2e/inject-fault { status: 429, retryAfter: 3, count: 2 }
Then the e2e test would:
1. Inject a fault (e.g. "next 2 requests to workflow handler return 429 with Retry-After: 3")
2. Start a workflow
3. Assert it completes successfully despite the transient errors
4. Optionally check logs/traces for the retry events
This is the most surgical approach but requires adding test-only infrastructure. The world-local queue already makes HTTP requests to the app endpoints, so you could intercept at that
layer.

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@changeset-bot

changeset-botBot commented Feb 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9f75d33

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

This PR includes changesets to release 19 packages
NameType
@workflow/errorsPatch
@workflow/worldPatch
@workflow/world-vercelPatch
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
workflowPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/web-sharedPatch
@workflow/world-testingPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

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

@vercel

vercelBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production490038528
✅ 💻 Local Development418062480
✅ 📦 Local Production418062480
✅ 🐘 Local Postgres418062480
✅ 🪟 Windows450348
❌ 🌍 Community Worlds105429156
✅ 📋 Other123021144
Total2017422572316

❌ Failed Tests

🌍 Community Worlds (42 failed)

mongodb (1 failed):

  • webhookWorkflow

turso (41 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro4404
✅ example4404
✅ express4404
✅ fastify4404
✅ hono4404
✅ nextjs-turbopack4701
✅ nextjs-webpack4701
✅ nitro4404
✅ nuxt4404
✅ sveltekit4404
✅ vite4404
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack4503
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
❌ mongodb4413
✅ redis-dev300
✅ redis4503
✅ starter-dev300
✅ turso-dev300
❌ turso4413
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4107
✅ e2e-local-postgres-nest-stable4107
✅ e2e-local-prod-nest-stable4107

📋 View full workflow run

@github-actions

github-actionsBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro0.032s (+22.9% 🔺)1.005s (~)0.973s101.00x
💻 LocalExpress0.032s (-2.7%)1.005s (~)0.973s101.02x
💻 LocalNext.js (Turbopack)0.046s1.005s0.959s101.44x
🌐 RedisNext.js (Turbopack)0.051s1.005s0.953s101.62x
🐘 PostgresNitro0.106s (+13.5% 🔺)1.009s (~)0.903s103.36x
🌐 MongoDBNext.js (Turbopack)0.110s1.007s0.897s103.48x
🐘 PostgresExpress0.194s (-45.0% 🟢)1.021s (-2.1%)0.827s106.12x
🐘 PostgresNext.js (Turbopack)0.444s1.009s0.565s1014.01x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.784s (-7.5% 🟢)2.468s (-4.7%)1.684s101.00x
▲ VercelNext.js (Turbopack)0.786s (-14.1% 🟢)2.385s (-1.5%)1.599s101.00x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.105s2.006s0.901s101.00x
💻 LocalNitro1.105s (+2.8%)2.006s (~)0.901s101.00x
💻 LocalExpress1.108s (~)2.006s (~)0.898s101.00x
🌐 RedisNext.js (Turbopack)1.110s2.006s0.896s101.00x
🌐 MongoDBNext.js (Turbopack)1.300s2.007s0.707s101.18x
🐘 PostgresNext.js (Turbopack)1.907s2.113s0.206s101.73x
🐘 PostgresExpress2.460s (+10.6% 🔺)3.014s (~)0.554s102.23x
🐘 PostgresNitro2.500s (+27.1% 🔺)3.013s (+36.1% 🔺)0.513s102.26x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.957s (+12.5% 🔺)3.946s (+7.1% 🔺)0.988s101.00x
▲ VercelExpress3.215s (+22.0% 🔺)4.569s (+21.6% 🔺)1.354s101.09x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.722s11.022s0.300s31.00x
💻 LocalNext.js (Turbopack)10.745s11.023s0.278s31.00x
💻 LocalNitro10.838s (+2.4%)11.022s (~)0.184s31.01x
💻 LocalExpress10.839s (~)11.024s (~)0.185s31.01x
🌐 MongoDBNext.js (Turbopack)12.349s13.025s0.677s31.15x
🐘 PostgresNext.js (Turbopack)15.512s16.049s0.537s21.45x
🐘 PostgresExpress20.303s (~)21.061s (~)0.758s21.89x
🐘 PostgresNitro20.372s (+32.8% 🔺)21.053s (+31.2% 🔺)0.682s21.90x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express18.895s (-5.9% 🟢)20.661s (-1.5%)1.766s21.00x
▲ VercelNext.js (Turbopack)21.076s (+8.2% 🔺)22.174s (+8.3% 🔺)1.098s21.12x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.866s27.050s0.185s31.00x
💻 LocalNext.js (Turbopack)27.305s28.051s0.746s31.02x
💻 LocalNitro27.454s (+2.6%)28.049s (+3.7%)0.596s31.02x
💻 LocalExpress27.557s (~)28.052s (~)0.495s31.03x
🌐 MongoDBNext.js (Turbopack)30.556s31.046s0.490s21.14x
🐘 PostgresNitro50.218s (+21.3% 🔺)51.134s (+21.4% 🔺)0.915s21.87x
🐘 PostgresExpress50.303s (~)50.630s (-1.0%)0.327s21.87x
🐘 PostgresNext.js (Turbopack)50.312s50.623s0.312s21.87x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express48.126s (-3.0%)48.969s (-5.3% 🟢)0.843s21.00x
▲ VercelNext.js (Turbopack)49.886s (-4.5%)51.786s (-3.7%)1.900s21.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)54.374s55.101s0.726s21.00x
💻 LocalNext.js (Turbopack)56.919s57.103s0.184s21.05x
💻 LocalNitro57.120s (+2.8%)58.098s (+3.6%)0.978s21.05x
💻 LocalExpress57.342s (~)58.106s (~)0.764s21.05x
🌐 MongoDBNext.js (Turbopack)61.157s61.592s0.435s21.12x
🐘 PostgresNext.js (Turbopack)91.740s92.206s0.466s11.69x
🐘 PostgresExpress100.159s (~)100.227s (-1.0%)0.068s11.84x
🐘 PostgresNitro100.479s (+26.0% 🔺)101.227s (+26.2% 🔺)0.748s11.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express101.457s (-0.6%)102.618s (-0.8%)1.161s11.00x
▲ VercelNext.js (Turbopack)104.877s (+1.8%)105.825s (+2.1%)0.948s11.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.254s2.006s0.753s151.00x
💻 LocalNitro1.405s (+4.5%)2.006s (~)0.601s151.12x
💻 LocalExpress1.416s (~)2.005s (~)0.589s151.13x
💻 LocalNext.js (Turbopack)1.432s2.005s0.573s151.14x
🐘 PostgresNext.js (Turbopack)2.095s2.474s0.379s131.67x
🌐 MongoDBNext.js (Turbopack)2.164s3.008s0.844s101.73x
🐘 PostgresExpress2.194s (-6.6% 🟢)3.014s (~)0.820s101.75x
🐘 PostgresNitro2.485s (+40.2% 🔺)3.014s (+45.0% 🔺)0.529s101.98x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.728s (+1.1%)3.889s (+4.9%)1.161s81.00x
▲ VercelExpress3.000s (+10.3% 🔺)4.204s (+9.7% 🔺)1.204s81.10x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)2.485s3.008s0.523s101.00x
💻 LocalNitro2.544s (+13.2% 🔺)3.007s (~)0.463s101.02x
💻 LocalExpress2.619s (+2.4%)3.007s (~)0.388s101.05x
💻 LocalNext.js (Turbopack)2.642s3.007s0.364s101.06x
🌐 MongoDBNext.js (Turbopack)4.767s5.178s0.411s61.92x
🐘 PostgresExpress8.884s (-0.5%)9.546s (+2.9%)0.661s43.58x
🐘 PostgresNitro9.960s (-10.4% 🟢)10.697s (-8.6% 🟢)0.737s34.01x
🐘 PostgresNext.js (Turbopack)12.648s13.037s0.389s35.09x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.552s (+9.2% 🔺)5.099s (+14.9% 🔺)1.547s61.00x
▲ VercelExpress3.700s (+17.3% 🔺)5.005s (+20.3% 🔺)1.305s71.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)4.067s4.725s0.658s71.00x
💻 LocalNitro7.071s (+19.5% 🔺)7.767s (+21.1% 🔺)0.696s41.74x
💻 LocalNext.js (Turbopack)7.627s8.016s0.390s41.88x
💻 LocalExpress7.708s (+5.2% 🔺)8.273s (+3.2%)0.565s41.90x
🌐 MongoDBNext.js (Turbopack)9.877s10.352s0.475s32.43x
🐘 PostgresNitro50.050s (-4.4%)50.119s (-5.7% 🟢)0.069s112.31x
🐘 PostgresExpress51.197s (+8.8% 🔺)52.128s (+8.4% 🔺)0.931s112.59x
🐘 PostgresNext.js (Turbopack)55.317s56.128s0.811s113.60x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.682s (-1.3%)5.309s (+5.1% 🔺)1.626s61.00x
▲ VercelExpress4.333s (+22.4% 🔺)5.927s (+17.6% 🔺)1.594s71.18x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.259s2.006s0.747s151.00x
💻 LocalNitro1.425s (+5.2% 🔺)2.004s (~)0.580s151.13x
💻 LocalExpress1.431s (~)2.006s (~)0.574s151.14x
💻 LocalNext.js (Turbopack)1.464s2.006s0.542s151.16x
🐘 PostgresNitro2.141s (+27.4% 🔺)2.831s (+36.3% 🔺)0.691s111.70x
🐘 PostgresExpress2.142s (+2.0%)2.741s (+5.6% 🔺)0.599s111.70x
🌐 MongoDBNext.js (Turbopack)2.169s3.008s0.839s101.72x
🐘 PostgresNext.js (Turbopack)2.308s2.741s0.432s111.83x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.631s (+1.9%)3.817s (-0.8%)1.186s81.00x
▲ VercelNext.js (Turbopack)2.777s (+2.8%)4.032s (+4.4%)1.254s81.06x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)2.493s3.008s0.515s101.00x
💻 LocalNitro2.635s (+9.4% 🔺)3.007s (~)0.372s101.06x
💻 LocalNext.js (Turbopack)2.722s3.008s0.285s101.09x
💻 LocalExpress2.837s (+3.1%)3.008s (~)0.170s101.14x
🌐 MongoDBNext.js (Turbopack)4.725s5.176s0.451s61.89x
🐘 PostgresExpress11.149s (+2.8%)11.698s (+2.9%)0.548s34.47x
🐘 PostgresNitro12.296s (+14.2% 🔺)13.039s (+14.7% 🔺)0.743s34.93x
🐘 PostgresNext.js (Turbopack)12.730s13.368s0.638s35.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.981s (-6.6% 🟢)4.089s (-0.8%)1.108s81.00x
▲ VercelNext.js (Turbopack)3.066s (-10.0% 🟢)3.923s (-15.5% 🟢)0.857s81.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)4.079s4.725s0.646s71.00x
💻 LocalNitro7.739s (+16.5% 🔺)8.016s (+14.3% 🔺)0.277s41.90x
💻 LocalNext.js (Turbopack)8.017s8.518s0.501s41.97x
💻 LocalExpress8.163s (+4.8%)9.023s (+12.5% 🔺)0.860s42.00x
🌐 MongoDBNext.js (Turbopack)9.734s10.348s0.614s32.39x
🐘 PostgresNitro52.679s (+7.4% 🔺)53.124s (+8.1% 🔺)0.445s112.91x
🐘 PostgresNext.js (Turbopack)54.508s55.127s0.619s113.36x
🐘 PostgresExpress54.742s (+4.8%)55.132s (+3.8%)0.390s113.42x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.188s (-6.3% 🟢)4.374s (-13.7% 🟢)1.186s71.00x
▲ VercelNext.js (Turbopack)3.623s (-14.2% 🟢)5.046s (-9.5% 🟢)1.422s61.14x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.147s1.001s0.011s1.016s0.869s101.00x
🌐 RedisNext.js (Turbopack)0.148s1.000s0.001s1.007s0.859s101.00x
💻 LocalNitro0.173s (+55.5% 🔺)1.002s (~)0.010s (+14.6% 🔺)1.015s (~)0.842s101.17x
💻 LocalExpress0.175s (~)1.002s (~)0.011s (+9.1% 🔺)1.016s (~)0.841s101.19x
🌐 MongoDBNext.js (Turbopack)0.496s0.951s0.001s1.009s0.513s103.37x
🐘 PostgresNext.js (Turbopack)1.459s1.650s0.001s2.012s0.553s109.90x
🐘 PostgresNitro2.144s (+68.7% 🔺)2.900s (+63.8% 🔺)0.001s (~)3.015s (+49.7% 🔺)0.871s1014.54x
🐘 PostgresExpress2.271s (+4.8%)2.771s (-3.6%)0.001s (-16.7% 🟢)3.015s (~)0.744s1015.41x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.396s (-6.6% 🟢)3.058s (-8.5% 🟢)0.167s (+8.2% 🔺)3.971s (-6.6% 🟢)1.575s101.00x
▲ VercelNext.js (Turbopack)2.609s (+8.1% 🔺)2.867s (-6.0% 🟢)0.198s (+87.7% 🔺)3.836s (-0.9%)1.227s101.09x
▲ VercelNitro⚠️missing-----

🔍 Observability: Express | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNitro7/12
🐘 PostgresNitro5/12
▲ VercelExpress8/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express💻 Local10/12
Next.js (Turbopack)🌐 Redis7/12
Nitro💻 Local12/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Starter: Community world (local development)
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

- Propagate Retry-After header as WorkflowAPIError.retryAfter on 429 responses
- Add withThrottleRetry wrapper for both workflow and step handlers
- Re-enqueue workflows on 5xx errors with exponential backoff (5s, 30s, 120min)
- Track serverErrorRetryCount in queue payload for retry budgeting
- Expose delaySeconds on QueueOptions interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
);
// Short wait: sleep in-process, then retry once
await new Promise((resolve) =>
setTimeout(resolve, retryAfterSeconds * 1000)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we account for function execution time limits specifically in the case of Vercel world? If the serverless fuction is already close to the end of it's limit and the workflow server throws a 429, adding a 10 sec sleep could potentially exceed the function execution limit and the function could get SIGKILLd midway.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

The workflow layer should never take much more than a few seconds, so I think it's highly unlikely that we'd run into timeouts, so I'm not too worried about this, but technically a concern

@karthikscale3

Copy link
Copy Markdown
Contributor

Rest of the code looks good except the one concern I have around function execution limits in the case of vercel world.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this looks solid — the 429 throttle retry and 5xx backoff are clean additions. The withThrottleRetry wrapper with the "safe to retry because 429 is pre-processing" invariant is a nice design. A few things to consider:

world-local doesn't support delaySeconds — The QueueOptions interface was updated and world-vercel passes it through, but world-local/src/queue.ts silently ignores opts?.delaySeconds. If a 5xx retry fires in local dev, the message will be re-enqueued but delivered immediately instead of after the intended backoff. Not a blocker since 5xx from world-local is uncommon, but could cause confusing behavior during local fault testing (which the PR description mentions wanting to add).

Step handler diff is mostly re-indentation — confirmed the actual logic changes are just wrapping in withThrottleRetry. The step handler does NOT get 5xx retry (only 429), which makes sense since the queue's built-in redelivery + step_started idempotency handles that.

@pranaygp

Copy link
Copy Markdown
Contributor

Inline review notes

Since GitHub's review API is having issues, posting inline comments here:


packages/core/src/runtime.ts:291delaySecondSteps = [5, 30, 7200]

The third backoff step is 7200s (2 hours). Is the expectation that persistent 5xx would trigger an alert/incident on the server side well before this fires? A brief comment explaining the rationale for these specific values would help future readers.


packages/core/src/runtime.ts:290-307serverErrorRetryCount lifecycle

The serverErrorRetryCount resets on each new workflow invocation (since the next queueMessage after a successful step won't include it). This means each invocation gets a fresh retry budget. That seems right for transient 5xx, but if the server is persistently down, each invocation will independently burn through all 3 retries before failing. Just want to confirm this is the intended behavior vs. tracking the count on the run itself.


packages/core/src/runtime/helpers.ts:425withThrottleRetry short-wait path

If the first in-process retry succeeds, great. But if it gets a different error (not 429), that error is thrown — which means the handler fails entirely rather than deferring to queue. This seems correct since a non-429 error should propagate, just want to confirm the intent.


packages/world-vercel/src/utils.ts:298Retry-After parsing

nit: Retry-After can also be an HTTP-date (RFC 9110). This only handles the delay-seconds format. Fine if you control the server, but worth a comment noting the assumption.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this is clean and well-structured. The two-tier 429 strategy (in-process for short waits, defer to queue for long waits) is nice. A few questions and concerns below.

*
* Safe to retry the entire handler because 429 is sent from server middleware
* before the request is processed — no server state has changed.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comment says "Safe to retry the entire handler because 429 is sent from server middleware before the request is processed — no server state has changed."

This holds if the 429 always hits on the first API call in the handler. But the handler makes multiple world API calls sequentially (e.g. runs.getevents.create(run_started) → replay → events.create(run_completed)). If a later call gets 429'd, the retry re-executes everything from the top.

For the workflow handler this is probably fine since replay is deterministic and events are idempotent.

For the step handler this is more concerning — the retry would re-execute user step code, which may not be idempotent. Is the assumption that the workflow-server's rate limiting middleware rejects at the connection level (so all calls in one handler invocation either succeed or all fail)? If so, worth documenting that assumption.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch, fixed

Comment threadpackages/core/src/runtime.ts Outdated
err.status >= 500
) {
const retryCount = serverErrorRetryCount ?? 0;
const delaySecondSteps = [5, 30, 7200]; // 5s, 30s, 120min

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The third step is 7200 seconds = 2 hours. Is that intentional? That's a very long time for a workflow to be stalled on a server error. If the 500 was truly transient, 2 hours feels excessive. If it's a sustained outage, you'd probably want to fail rather than retry after 2h when the server might have moved on.

Maybe something like [5, 30, 120] (5s, 30s, 2min) would be more practical?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Changed to [5, 30, 120]. Realistically if we keep getting 500s, it'd be nice to re-try the run much later so we have a change to incident-mitigate, but I think it's fine to fail runs instead too

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

cool. this was fully AI generated review btw. idk where it came up with 5s, 30, 120 - but I do agree i we should have some shorter ones before getting to 7200 anyway

}

const maxRetries = stepFn.maxRetries ?? DEFAULT_STEP_MAX_RETRIES;
return await withThrottleRetry(async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The step handler gets withThrottleRetry for 429s but no equivalent 5xx retry logic like the workflow handler has. If a world API call (e.g. events.create for step_started or step_completed) returns a 5xx, it'll be caught by the step's error handling and burn a step retry attempt — re-executing potentially expensive user code for what was a transient infrastructure error.

Is this intentional for this PR's scope, or should step handlers also get 5xx backoff retry?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

500s are handled via regular step re-try. 429s are only special-cased because they allow the step-retry mechanism to use the retry-after header instead of guessing at the retry

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

right but the comment is about not burning a full step attempt for transient 5xx. doesn't need to be addressed in this PR necessarily but good to think about. happy to move in any direction for now since they're all better than nothing and then come back to this in the future with real experience

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Makes sense that steps rely on their existing retry mechanism. One concern though: with withThrottleRetry removed, a 429 from the world API during step execution (e.g. on step_started or step_completed) will now burn a step retry attempt — potentially exhausting maxRetries without the user's code ever being at fault.

Ideally 429s should be retried transparently without consuming an attempt. A targeted retry around just the world API calls (not wrapping the user code execution) could thread that needle. Worth tracking as a follow-up.

idempotencyKey?: string;
headers?: Record<string, string>;
/** Delay message delivery by this many seconds */
delaySeconds?: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good that this was promoted from the vercel-specific type to the shared interface. Note that world-local currently ignores delaySeconds entirely — so 5xx retries in local dev will fire immediately instead of with backoff. Not a blocker but worth a follow-up or at minimum a comment.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Seems fine to ignore for local world, since there are no 429s

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

although we probably do actually want to support delaySeconds in local world anyway as we start using this option in queue more often in the future for more use cases. even if it's not implemented in this PR, I think we should leave an explicit comment that local world ignores this since it's a nuance. We should later have an e2e test that checks for this behaviour and would fail on local world without proper queue delaySeconds implementation (cc @TooTallNate )

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/src/runtime/step-handler.ts Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good! The removal of withThrottleRetry from the step handler was the right fix for the re-execution safety concern.

One thing worth tracking as a follow-up: with withThrottleRetry removed from steps, a 429 from the world API during step execution (e.g. on step_started or step_completed) will now burn a step retry attempt. This means a transient throttle from the server could exhaust a step's maxRetries without the user's code ever being at fault. Ideally 429s should be retried transparently without consuming an attempt — maybe a targeted retry around just the world API calls (not the user code execution) would thread that needle.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: [core] Handle 429 and 500 errors from worlds in runtime

Overall this is a solid approach to handling transient server errors. The 429 throttle retry with short/long path split is well-designed, and the 500 retry via re-enqueue with exponential backoff is a safe pattern. A few items to address below.

);
runtimeLogger.warn(
'Throttled again on retry, deferring to queue',
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redundant check: this is already inside if (WorkflowAPIError.is(err)), so the second WorkflowAPIError.is(err) on this line is always true. Should just be if (err.status === 429).

step = startResult.step;
} catch (err) {
if (WorkflowAPIError.is(err)) {
if (WorkflowAPIError.is(err) && err.status === 429) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The step handler has 429 handling here inside the step_started catch block, but unlike the workflow handler, it does not use withThrottleRetry and does not have the short-wait in-process retry path. This means a brief 429 (e.g., retryAfter=2s) will always defer to the queue rather than sleeping in-process. Is that intentional? If steps should also get the in-process retry for short waits, consider wrapping with withThrottleRetry similarly to the workflow handler.

if (retryAfterSeconds < 10) {
runtimeLogger.warn(
'Throttled by workflow-server (429), retrying in-process',
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: if retryAfter is undefined (header missing), this defaults to 1 second. For 429 responses without a Retry-After header, 1 second might be too aggressive. A slightly higher default (e.g., 3-5s) would be more conservative and reduce the chance of hitting the server again immediately while it's under load.

workflowStartedAt = +workflowRun.startedAt;

// At this point, the workflow is "running" and `startedAt` should
// definitely be set.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The entire workflow handler body is now wrapped in withThrottleRetry. This means if a 429 is thrown by any world call during workflow execution (e.g., world.runs.get, world.events.create, runWorkflow), the entire handler will be retried from scratch.

  1. If run_started event was already created successfully and then a later call throws 429, the retry will call world.runs.get again and find the run already running, which is handled correctly.
  2. However, if run_completed event creation throws 429, the retry would re-run the entire workflow (replay + execution). The workflow replay should be deterministic, but this could be expensive. Worth documenting this tradeoff in a comment.

});
// Use the run entity from the event response (no extra get call needed)
if (!result.run) {
if (workflowRun.status === 'pending') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The 500 retry re-enqueues with a new requestedAt and incremented serverErrorRetryCount. The traceCarrier is serialized fresh via serializeTraceCarrier(). If the trace context matters for correlating retries, this creates a new trace for each retry attempt. Consider whether preserving the original traceContext from the message would be preferable for observability (linking all retry attempts to the same parent trace).

WorldParseFormat,
PeerService,
RpcSystem,
RpcService,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note: the HTTP Retry-After header can also be an HTTP-date (e.g., Wed, 21 Oct 2015 07:28:00 GMT). The parseInt approach correctly returns NaN for date values, falling back to undefined. This is fine since the workflow-server likely only sends numeric values, but a comment noting this would be helpful.

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/src/runtime/helpers.ts Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
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.

3 participants

@VaguelySerious@karthikscale3@pranaygp
, '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

[core] Handle 429 and 500 errors from worlds in runtime - #966

Merged
VaguelySerious merged 12 commits into
mainfrom
peter/429-core
Feb 9, 2026
Merged

[core] Handle 429 and 500 errors from worlds in runtime#966
VaguelySerious merged 12 commits into
mainfrom
peter/429-core

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Feb 6, 2026

Copy link
Copy Markdown
Member

Uses retry-after header for 429 when provided.
500s are limited to 3x retry, doing exponential backoff.

Review with white space off for sanity.

Claude's suggestion for e2e tests, which I think we might do separately. I think we should make some sort of testbench for world errors and how the runtime reacts.

 3. E2E with a fault-injection endpoint (moderate effort, very targeted)
Add a test-only endpoint to the workbench app (or a middleware) that can be told to return 429/500 for the next N requests. Something like:
POST /e2e/inject-fault { status: 429, retryAfter: 3, count: 2 }
Then the e2e test would:
1. Inject a fault (e.g. "next 2 requests to workflow handler return 429 with Retry-After: 3")
2. Start a workflow
3. Assert it completes successfully despite the transient errors
4. Optionally check logs/traces for the retry events
This is the most surgical approach but requires adding test-only infrastructure. The world-local queue already makes HTTP requests to the app endpoints, so you could intercept at that
layer.

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@changeset-bot

changeset-botBot commented Feb 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9f75d33

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

This PR includes changesets to release 19 packages
NameType
@workflow/errorsPatch
@workflow/worldPatch
@workflow/world-vercelPatch
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
workflowPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/web-sharedPatch
@workflow/world-testingPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

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

@vercel

vercelBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production490038528
✅ 💻 Local Development418062480
✅ 📦 Local Production418062480
✅ 🐘 Local Postgres418062480
✅ 🪟 Windows450348
❌ 🌍 Community Worlds105429156
✅ 📋 Other123021144
Total2017422572316

❌ Failed Tests

🌍 Community Worlds (42 failed)

mongodb (1 failed):

  • webhookWorkflow

turso (41 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro4404
✅ example4404
✅ express4404
✅ fastify4404
✅ hono4404
✅ nextjs-turbopack4701
✅ nextjs-webpack4701
✅ nitro4404
✅ nuxt4404
✅ sveltekit4404
✅ vite4404
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack4503
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
❌ mongodb4413
✅ redis-dev300
✅ redis4503
✅ starter-dev300
✅ turso-dev300
❌ turso4413
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4107
✅ e2e-local-postgres-nest-stable4107
✅ e2e-local-prod-nest-stable4107

📋 View full workflow run

@github-actions

github-actionsBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro0.032s (+22.9% 🔺)1.005s (~)0.973s101.00x
💻 LocalExpress0.032s (-2.7%)1.005s (~)0.973s101.02x
💻 LocalNext.js (Turbopack)0.046s1.005s0.959s101.44x
🌐 RedisNext.js (Turbopack)0.051s1.005s0.953s101.62x
🐘 PostgresNitro0.106s (+13.5% 🔺)1.009s (~)0.903s103.36x
🌐 MongoDBNext.js (Turbopack)0.110s1.007s0.897s103.48x
🐘 PostgresExpress0.194s (-45.0% 🟢)1.021s (-2.1%)0.827s106.12x
🐘 PostgresNext.js (Turbopack)0.444s1.009s0.565s1014.01x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.784s (-7.5% 🟢)2.468s (-4.7%)1.684s101.00x
▲ VercelNext.js (Turbopack)0.786s (-14.1% 🟢)2.385s (-1.5%)1.599s101.00x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.105s2.006s0.901s101.00x
💻 LocalNitro1.105s (+2.8%)2.006s (~)0.901s101.00x
💻 LocalExpress1.108s (~)2.006s (~)0.898s101.00x
🌐 RedisNext.js (Turbopack)1.110s2.006s0.896s101.00x
🌐 MongoDBNext.js (Turbopack)1.300s2.007s0.707s101.18x
🐘 PostgresNext.js (Turbopack)1.907s2.113s0.206s101.73x
🐘 PostgresExpress2.460s (+10.6% 🔺)3.014s (~)0.554s102.23x
🐘 PostgresNitro2.500s (+27.1% 🔺)3.013s (+36.1% 🔺)0.513s102.26x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.957s (+12.5% 🔺)3.946s (+7.1% 🔺)0.988s101.00x
▲ VercelExpress3.215s (+22.0% 🔺)4.569s (+21.6% 🔺)1.354s101.09x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.722s11.022s0.300s31.00x
💻 LocalNext.js (Turbopack)10.745s11.023s0.278s31.00x
💻 LocalNitro10.838s (+2.4%)11.022s (~)0.184s31.01x
💻 LocalExpress10.839s (~)11.024s (~)0.185s31.01x
🌐 MongoDBNext.js (Turbopack)12.349s13.025s0.677s31.15x
🐘 PostgresNext.js (Turbopack)15.512s16.049s0.537s21.45x
🐘 PostgresExpress20.303s (~)21.061s (~)0.758s21.89x
🐘 PostgresNitro20.372s (+32.8% 🔺)21.053s (+31.2% 🔺)0.682s21.90x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express18.895s (-5.9% 🟢)20.661s (-1.5%)1.766s21.00x
▲ VercelNext.js (Turbopack)21.076s (+8.2% 🔺)22.174s (+8.3% 🔺)1.098s21.12x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.866s27.050s0.185s31.00x
💻 LocalNext.js (Turbopack)27.305s28.051s0.746s31.02x
💻 LocalNitro27.454s (+2.6%)28.049s (+3.7%)0.596s31.02x
💻 LocalExpress27.557s (~)28.052s (~)0.495s31.03x
🌐 MongoDBNext.js (Turbopack)30.556s31.046s0.490s21.14x
🐘 PostgresNitro50.218s (+21.3% 🔺)51.134s (+21.4% 🔺)0.915s21.87x
🐘 PostgresExpress50.303s (~)50.630s (-1.0%)0.327s21.87x
🐘 PostgresNext.js (Turbopack)50.312s50.623s0.312s21.87x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express48.126s (-3.0%)48.969s (-5.3% 🟢)0.843s21.00x
▲ VercelNext.js (Turbopack)49.886s (-4.5%)51.786s (-3.7%)1.900s21.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)54.374s55.101s0.726s21.00x
💻 LocalNext.js (Turbopack)56.919s57.103s0.184s21.05x
💻 LocalNitro57.120s (+2.8%)58.098s (+3.6%)0.978s21.05x
💻 LocalExpress57.342s (~)58.106s (~)0.764s21.05x
🌐 MongoDBNext.js (Turbopack)61.157s61.592s0.435s21.12x
🐘 PostgresNext.js (Turbopack)91.740s92.206s0.466s11.69x
🐘 PostgresExpress100.159s (~)100.227s (-1.0%)0.068s11.84x
🐘 PostgresNitro100.479s (+26.0% 🔺)101.227s (+26.2% 🔺)0.748s11.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express101.457s (-0.6%)102.618s (-0.8%)1.161s11.00x
▲ VercelNext.js (Turbopack)104.877s (+1.8%)105.825s (+2.1%)0.948s11.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.254s2.006s0.753s151.00x
💻 LocalNitro1.405s (+4.5%)2.006s (~)0.601s151.12x
💻 LocalExpress1.416s (~)2.005s (~)0.589s151.13x
💻 LocalNext.js (Turbopack)1.432s2.005s0.573s151.14x
🐘 PostgresNext.js (Turbopack)2.095s2.474s0.379s131.67x
🌐 MongoDBNext.js (Turbopack)2.164s3.008s0.844s101.73x
🐘 PostgresExpress2.194s (-6.6% 🟢)3.014s (~)0.820s101.75x
🐘 PostgresNitro2.485s (+40.2% 🔺)3.014s (+45.0% 🔺)0.529s101.98x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.728s (+1.1%)3.889s (+4.9%)1.161s81.00x
▲ VercelExpress3.000s (+10.3% 🔺)4.204s (+9.7% 🔺)1.204s81.10x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)2.485s3.008s0.523s101.00x
💻 LocalNitro2.544s (+13.2% 🔺)3.007s (~)0.463s101.02x
💻 LocalExpress2.619s (+2.4%)3.007s (~)0.388s101.05x
💻 LocalNext.js (Turbopack)2.642s3.007s0.364s101.06x
🌐 MongoDBNext.js (Turbopack)4.767s5.178s0.411s61.92x
🐘 PostgresExpress8.884s (-0.5%)9.546s (+2.9%)0.661s43.58x
🐘 PostgresNitro9.960s (-10.4% 🟢)10.697s (-8.6% 🟢)0.737s34.01x
🐘 PostgresNext.js (Turbopack)12.648s13.037s0.389s35.09x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.552s (+9.2% 🔺)5.099s (+14.9% 🔺)1.547s61.00x
▲ VercelExpress3.700s (+17.3% 🔺)5.005s (+20.3% 🔺)1.305s71.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)4.067s4.725s0.658s71.00x
💻 LocalNitro7.071s (+19.5% 🔺)7.767s (+21.1% 🔺)0.696s41.74x
💻 LocalNext.js (Turbopack)7.627s8.016s0.390s41.88x
💻 LocalExpress7.708s (+5.2% 🔺)8.273s (+3.2%)0.565s41.90x
🌐 MongoDBNext.js (Turbopack)9.877s10.352s0.475s32.43x
🐘 PostgresNitro50.050s (-4.4%)50.119s (-5.7% 🟢)0.069s112.31x
🐘 PostgresExpress51.197s (+8.8% 🔺)52.128s (+8.4% 🔺)0.931s112.59x
🐘 PostgresNext.js (Turbopack)55.317s56.128s0.811s113.60x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.682s (-1.3%)5.309s (+5.1% 🔺)1.626s61.00x
▲ VercelExpress4.333s (+22.4% 🔺)5.927s (+17.6% 🔺)1.594s71.18x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.259s2.006s0.747s151.00x
💻 LocalNitro1.425s (+5.2% 🔺)2.004s (~)0.580s151.13x
💻 LocalExpress1.431s (~)2.006s (~)0.574s151.14x
💻 LocalNext.js (Turbopack)1.464s2.006s0.542s151.16x
🐘 PostgresNitro2.141s (+27.4% 🔺)2.831s (+36.3% 🔺)0.691s111.70x
🐘 PostgresExpress2.142s (+2.0%)2.741s (+5.6% 🔺)0.599s111.70x
🌐 MongoDBNext.js (Turbopack)2.169s3.008s0.839s101.72x
🐘 PostgresNext.js (Turbopack)2.308s2.741s0.432s111.83x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.631s (+1.9%)3.817s (-0.8%)1.186s81.00x
▲ VercelNext.js (Turbopack)2.777s (+2.8%)4.032s (+4.4%)1.254s81.06x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)2.493s3.008s0.515s101.00x
💻 LocalNitro2.635s (+9.4% 🔺)3.007s (~)0.372s101.06x
💻 LocalNext.js (Turbopack)2.722s3.008s0.285s101.09x
💻 LocalExpress2.837s (+3.1%)3.008s (~)0.170s101.14x
🌐 MongoDBNext.js (Turbopack)4.725s5.176s0.451s61.89x
🐘 PostgresExpress11.149s (+2.8%)11.698s (+2.9%)0.548s34.47x
🐘 PostgresNitro12.296s (+14.2% 🔺)13.039s (+14.7% 🔺)0.743s34.93x
🐘 PostgresNext.js (Turbopack)12.730s13.368s0.638s35.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.981s (-6.6% 🟢)4.089s (-0.8%)1.108s81.00x
▲ VercelNext.js (Turbopack)3.066s (-10.0% 🟢)3.923s (-15.5% 🟢)0.857s81.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)4.079s4.725s0.646s71.00x
💻 LocalNitro7.739s (+16.5% 🔺)8.016s (+14.3% 🔺)0.277s41.90x
💻 LocalNext.js (Turbopack)8.017s8.518s0.501s41.97x
💻 LocalExpress8.163s (+4.8%)9.023s (+12.5% 🔺)0.860s42.00x
🌐 MongoDBNext.js (Turbopack)9.734s10.348s0.614s32.39x
🐘 PostgresNitro52.679s (+7.4% 🔺)53.124s (+8.1% 🔺)0.445s112.91x
🐘 PostgresNext.js (Turbopack)54.508s55.127s0.619s113.36x
🐘 PostgresExpress54.742s (+4.8%)55.132s (+3.8%)0.390s113.42x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.188s (-6.3% 🟢)4.374s (-13.7% 🟢)1.186s71.00x
▲ VercelNext.js (Turbopack)3.623s (-14.2% 🟢)5.046s (-9.5% 🟢)1.422s61.14x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.147s1.001s0.011s1.016s0.869s101.00x
🌐 RedisNext.js (Turbopack)0.148s1.000s0.001s1.007s0.859s101.00x
💻 LocalNitro0.173s (+55.5% 🔺)1.002s (~)0.010s (+14.6% 🔺)1.015s (~)0.842s101.17x
💻 LocalExpress0.175s (~)1.002s (~)0.011s (+9.1% 🔺)1.016s (~)0.841s101.19x
🌐 MongoDBNext.js (Turbopack)0.496s0.951s0.001s1.009s0.513s103.37x
🐘 PostgresNext.js (Turbopack)1.459s1.650s0.001s2.012s0.553s109.90x
🐘 PostgresNitro2.144s (+68.7% 🔺)2.900s (+63.8% 🔺)0.001s (~)3.015s (+49.7% 🔺)0.871s1014.54x
🐘 PostgresExpress2.271s (+4.8%)2.771s (-3.6%)0.001s (-16.7% 🟢)3.015s (~)0.744s1015.41x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.396s (-6.6% 🟢)3.058s (-8.5% 🟢)0.167s (+8.2% 🔺)3.971s (-6.6% 🟢)1.575s101.00x
▲ VercelNext.js (Turbopack)2.609s (+8.1% 🔺)2.867s (-6.0% 🟢)0.198s (+87.7% 🔺)3.836s (-0.9%)1.227s101.09x
▲ VercelNitro⚠️missing-----

🔍 Observability: Express | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNitro7/12
🐘 PostgresNitro5/12
▲ VercelExpress8/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express💻 Local10/12
Next.js (Turbopack)🌐 Redis7/12
Nitro💻 Local12/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Starter: Community world (local development)
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

- Propagate Retry-After header as WorkflowAPIError.retryAfter on 429 responses
- Add withThrottleRetry wrapper for both workflow and step handlers
- Re-enqueue workflows on 5xx errors with exponential backoff (5s, 30s, 120min)
- Track serverErrorRetryCount in queue payload for retry budgeting
- Expose delaySeconds on QueueOptions interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
);
// Short wait: sleep in-process, then retry once
await new Promise((resolve) =>
setTimeout(resolve, retryAfterSeconds * 1000)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we account for function execution time limits specifically in the case of Vercel world? If the serverless fuction is already close to the end of it's limit and the workflow server throws a 429, adding a 10 sec sleep could potentially exceed the function execution limit and the function could get SIGKILLd midway.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

The workflow layer should never take much more than a few seconds, so I think it's highly unlikely that we'd run into timeouts, so I'm not too worried about this, but technically a concern

@karthikscale3

Copy link
Copy Markdown
Contributor

Rest of the code looks good except the one concern I have around function execution limits in the case of vercel world.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this looks solid — the 429 throttle retry and 5xx backoff are clean additions. The withThrottleRetry wrapper with the "safe to retry because 429 is pre-processing" invariant is a nice design. A few things to consider:

world-local doesn't support delaySeconds — The QueueOptions interface was updated and world-vercel passes it through, but world-local/src/queue.ts silently ignores opts?.delaySeconds. If a 5xx retry fires in local dev, the message will be re-enqueued but delivered immediately instead of after the intended backoff. Not a blocker since 5xx from world-local is uncommon, but could cause confusing behavior during local fault testing (which the PR description mentions wanting to add).

Step handler diff is mostly re-indentation — confirmed the actual logic changes are just wrapping in withThrottleRetry. The step handler does NOT get 5xx retry (only 429), which makes sense since the queue's built-in redelivery + step_started idempotency handles that.

@pranaygp

Copy link
Copy Markdown
Contributor

Inline review notes

Since GitHub's review API is having issues, posting inline comments here:


packages/core/src/runtime.ts:291delaySecondSteps = [5, 30, 7200]

The third backoff step is 7200s (2 hours). Is the expectation that persistent 5xx would trigger an alert/incident on the server side well before this fires? A brief comment explaining the rationale for these specific values would help future readers.


packages/core/src/runtime.ts:290-307serverErrorRetryCount lifecycle

The serverErrorRetryCount resets on each new workflow invocation (since the next queueMessage after a successful step won't include it). This means each invocation gets a fresh retry budget. That seems right for transient 5xx, but if the server is persistently down, each invocation will independently burn through all 3 retries before failing. Just want to confirm this is the intended behavior vs. tracking the count on the run itself.


packages/core/src/runtime/helpers.ts:425withThrottleRetry short-wait path

If the first in-process retry succeeds, great. But if it gets a different error (not 429), that error is thrown — which means the handler fails entirely rather than deferring to queue. This seems correct since a non-429 error should propagate, just want to confirm the intent.


packages/world-vercel/src/utils.ts:298Retry-After parsing

nit: Retry-After can also be an HTTP-date (RFC 9110). This only handles the delay-seconds format. Fine if you control the server, but worth a comment noting the assumption.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this is clean and well-structured. The two-tier 429 strategy (in-process for short waits, defer to queue for long waits) is nice. A few questions and concerns below.

*
* Safe to retry the entire handler because 429 is sent from server middleware
* before the request is processed — no server state has changed.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comment says "Safe to retry the entire handler because 429 is sent from server middleware before the request is processed — no server state has changed."

This holds if the 429 always hits on the first API call in the handler. But the handler makes multiple world API calls sequentially (e.g. runs.getevents.create(run_started) → replay → events.create(run_completed)). If a later call gets 429'd, the retry re-executes everything from the top.

For the workflow handler this is probably fine since replay is deterministic and events are idempotent.

For the step handler this is more concerning — the retry would re-execute user step code, which may not be idempotent. Is the assumption that the workflow-server's rate limiting middleware rejects at the connection level (so all calls in one handler invocation either succeed or all fail)? If so, worth documenting that assumption.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch, fixed

Comment threadpackages/core/src/runtime.ts Outdated
err.status >= 500
) {
const retryCount = serverErrorRetryCount ?? 0;
const delaySecondSteps = [5, 30, 7200]; // 5s, 30s, 120min

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The third step is 7200 seconds = 2 hours. Is that intentional? That's a very long time for a workflow to be stalled on a server error. If the 500 was truly transient, 2 hours feels excessive. If it's a sustained outage, you'd probably want to fail rather than retry after 2h when the server might have moved on.

Maybe something like [5, 30, 120] (5s, 30s, 2min) would be more practical?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Changed to [5, 30, 120]. Realistically if we keep getting 500s, it'd be nice to re-try the run much later so we have a change to incident-mitigate, but I think it's fine to fail runs instead too

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

cool. this was fully AI generated review btw. idk where it came up with 5s, 30, 120 - but I do agree i we should have some shorter ones before getting to 7200 anyway

}

const maxRetries = stepFn.maxRetries ?? DEFAULT_STEP_MAX_RETRIES;
return await withThrottleRetry(async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The step handler gets withThrottleRetry for 429s but no equivalent 5xx retry logic like the workflow handler has. If a world API call (e.g. events.create for step_started or step_completed) returns a 5xx, it'll be caught by the step's error handling and burn a step retry attempt — re-executing potentially expensive user code for what was a transient infrastructure error.

Is this intentional for this PR's scope, or should step handlers also get 5xx backoff retry?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

500s are handled via regular step re-try. 429s are only special-cased because they allow the step-retry mechanism to use the retry-after header instead of guessing at the retry

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

right but the comment is about not burning a full step attempt for transient 5xx. doesn't need to be addressed in this PR necessarily but good to think about. happy to move in any direction for now since they're all better than nothing and then come back to this in the future with real experience

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Makes sense that steps rely on their existing retry mechanism. One concern though: with withThrottleRetry removed, a 429 from the world API during step execution (e.g. on step_started or step_completed) will now burn a step retry attempt — potentially exhausting maxRetries without the user's code ever being at fault.

Ideally 429s should be retried transparently without consuming an attempt. A targeted retry around just the world API calls (not wrapping the user code execution) could thread that needle. Worth tracking as a follow-up.

idempotencyKey?: string;
headers?: Record<string, string>;
/** Delay message delivery by this many seconds */
delaySeconds?: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good that this was promoted from the vercel-specific type to the shared interface. Note that world-local currently ignores delaySeconds entirely — so 5xx retries in local dev will fire immediately instead of with backoff. Not a blocker but worth a follow-up or at minimum a comment.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Seems fine to ignore for local world, since there are no 429s

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

although we probably do actually want to support delaySeconds in local world anyway as we start using this option in queue more often in the future for more use cases. even if it's not implemented in this PR, I think we should leave an explicit comment that local world ignores this since it's a nuance. We should later have an e2e test that checks for this behaviour and would fail on local world without proper queue delaySeconds implementation (cc @TooTallNate )

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/src/runtime/step-handler.ts Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good! The removal of withThrottleRetry from the step handler was the right fix for the re-execution safety concern.

One thing worth tracking as a follow-up: with withThrottleRetry removed from steps, a 429 from the world API during step execution (e.g. on step_started or step_completed) will now burn a step retry attempt. This means a transient throttle from the server could exhaust a step's maxRetries without the user's code ever being at fault. Ideally 429s should be retried transparently without consuming an attempt — maybe a targeted retry around just the world API calls (not the user code execution) would thread that needle.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: [core] Handle 429 and 500 errors from worlds in runtime

Overall this is a solid approach to handling transient server errors. The 429 throttle retry with short/long path split is well-designed, and the 500 retry via re-enqueue with exponential backoff is a safe pattern. A few items to address below.

);
runtimeLogger.warn(
'Throttled again on retry, deferring to queue',
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redundant check: this is already inside if (WorkflowAPIError.is(err)), so the second WorkflowAPIError.is(err) on this line is always true. Should just be if (err.status === 429).

step = startResult.step;
} catch (err) {
if (WorkflowAPIError.is(err)) {
if (WorkflowAPIError.is(err) && err.status === 429) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The step handler has 429 handling here inside the step_started catch block, but unlike the workflow handler, it does not use withThrottleRetry and does not have the short-wait in-process retry path. This means a brief 429 (e.g., retryAfter=2s) will always defer to the queue rather than sleeping in-process. Is that intentional? If steps should also get the in-process retry for short waits, consider wrapping with withThrottleRetry similarly to the workflow handler.

if (retryAfterSeconds < 10) {
runtimeLogger.warn(
'Throttled by workflow-server (429), retrying in-process',
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: if retryAfter is undefined (header missing), this defaults to 1 second. For 429 responses without a Retry-After header, 1 second might be too aggressive. A slightly higher default (e.g., 3-5s) would be more conservative and reduce the chance of hitting the server again immediately while it's under load.

workflowStartedAt = +workflowRun.startedAt;

// At this point, the workflow is "running" and `startedAt` should
// definitely be set.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The entire workflow handler body is now wrapped in withThrottleRetry. This means if a 429 is thrown by any world call during workflow execution (e.g., world.runs.get, world.events.create, runWorkflow), the entire handler will be retried from scratch.

  1. If run_started event was already created successfully and then a later call throws 429, the retry will call world.runs.get again and find the run already running, which is handled correctly.
  2. However, if run_completed event creation throws 429, the retry would re-run the entire workflow (replay + execution). The workflow replay should be deterministic, but this could be expensive. Worth documenting this tradeoff in a comment.

});
// Use the run entity from the event response (no extra get call needed)
if (!result.run) {
if (workflowRun.status === 'pending') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The 500 retry re-enqueues with a new requestedAt and incremented serverErrorRetryCount. The traceCarrier is serialized fresh via serializeTraceCarrier(). If the trace context matters for correlating retries, this creates a new trace for each retry attempt. Consider whether preserving the original traceContext from the message would be preferable for observability (linking all retry attempts to the same parent trace).

WorldParseFormat,
PeerService,
RpcSystem,
RpcService,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note: the HTTP Retry-After header can also be an HTTP-date (e.g., Wed, 21 Oct 2015 07:28:00 GMT). The parseInt approach correctly returns NaN for date values, falling back to undefined. This is fine since the workflow-server likely only sends numeric values, but a comment noting this would be helpful.

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/src/runtime/helpers.ts Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
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.

3 participants

@VaguelySerious@karthikscale3@pranaygp
, '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

[core] Handle 429 and 500 errors from worlds in runtime - #966

Merged
VaguelySerious merged 12 commits into
mainfrom
peter/429-core
Feb 9, 2026
Merged

[core] Handle 429 and 500 errors from worlds in runtime#966
VaguelySerious merged 12 commits into
mainfrom
peter/429-core

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Feb 6, 2026

Copy link
Copy Markdown
Member

Uses retry-after header for 429 when provided.
500s are limited to 3x retry, doing exponential backoff.

Review with white space off for sanity.

Claude's suggestion for e2e tests, which I think we might do separately. I think we should make some sort of testbench for world errors and how the runtime reacts.

 3. E2E with a fault-injection endpoint (moderate effort, very targeted)
Add a test-only endpoint to the workbench app (or a middleware) that can be told to return 429/500 for the next N requests. Something like:
POST /e2e/inject-fault { status: 429, retryAfter: 3, count: 2 }
Then the e2e test would:
1. Inject a fault (e.g. "next 2 requests to workflow handler return 429 with Retry-After: 3")
2. Start a workflow
3. Assert it completes successfully despite the transient errors
4. Optionally check logs/traces for the retry events
This is the most surgical approach but requires adding test-only infrastructure. The world-local queue already makes HTTP requests to the app endpoints, so you could intercept at that
layer.

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@changeset-bot

changeset-botBot commented Feb 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9f75d33

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

This PR includes changesets to release 19 packages
NameType
@workflow/errorsPatch
@workflow/worldPatch
@workflow/world-vercelPatch
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
workflowPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/web-sharedPatch
@workflow/world-testingPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

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

@vercel

vercelBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production490038528
✅ 💻 Local Development418062480
✅ 📦 Local Production418062480
✅ 🐘 Local Postgres418062480
✅ 🪟 Windows450348
❌ 🌍 Community Worlds105429156
✅ 📋 Other123021144
Total2017422572316

❌ Failed Tests

🌍 Community Worlds (42 failed)

mongodb (1 failed):

  • webhookWorkflow

turso (41 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro4404
✅ example4404
✅ express4404
✅ fastify4404
✅ hono4404
✅ nextjs-turbopack4701
✅ nextjs-webpack4701
✅ nitro4404
✅ nuxt4404
✅ sveltekit4404
✅ vite4404
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack4503
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
❌ mongodb4413
✅ redis-dev300
✅ redis4503
✅ starter-dev300
✅ turso-dev300
❌ turso4413
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4107
✅ e2e-local-postgres-nest-stable4107
✅ e2e-local-prod-nest-stable4107

📋 View full workflow run

@github-actions

github-actionsBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro0.032s (+22.9% 🔺)1.005s (~)0.973s101.00x
💻 LocalExpress0.032s (-2.7%)1.005s (~)0.973s101.02x
💻 LocalNext.js (Turbopack)0.046s1.005s0.959s101.44x
🌐 RedisNext.js (Turbopack)0.051s1.005s0.953s101.62x
🐘 PostgresNitro0.106s (+13.5% 🔺)1.009s (~)0.903s103.36x
🌐 MongoDBNext.js (Turbopack)0.110s1.007s0.897s103.48x
🐘 PostgresExpress0.194s (-45.0% 🟢)1.021s (-2.1%)0.827s106.12x
🐘 PostgresNext.js (Turbopack)0.444s1.009s0.565s1014.01x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.784s (-7.5% 🟢)2.468s (-4.7%)1.684s101.00x
▲ VercelNext.js (Turbopack)0.786s (-14.1% 🟢)2.385s (-1.5%)1.599s101.00x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.105s2.006s0.901s101.00x
💻 LocalNitro1.105s (+2.8%)2.006s (~)0.901s101.00x
💻 LocalExpress1.108s (~)2.006s (~)0.898s101.00x
🌐 RedisNext.js (Turbopack)1.110s2.006s0.896s101.00x
🌐 MongoDBNext.js (Turbopack)1.300s2.007s0.707s101.18x
🐘 PostgresNext.js (Turbopack)1.907s2.113s0.206s101.73x
🐘 PostgresExpress2.460s (+10.6% 🔺)3.014s (~)0.554s102.23x
🐘 PostgresNitro2.500s (+27.1% 🔺)3.013s (+36.1% 🔺)0.513s102.26x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.957s (+12.5% 🔺)3.946s (+7.1% 🔺)0.988s101.00x
▲ VercelExpress3.215s (+22.0% 🔺)4.569s (+21.6% 🔺)1.354s101.09x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.722s11.022s0.300s31.00x
💻 LocalNext.js (Turbopack)10.745s11.023s0.278s31.00x
💻 LocalNitro10.838s (+2.4%)11.022s (~)0.184s31.01x
💻 LocalExpress10.839s (~)11.024s (~)0.185s31.01x
🌐 MongoDBNext.js (Turbopack)12.349s13.025s0.677s31.15x
🐘 PostgresNext.js (Turbopack)15.512s16.049s0.537s21.45x
🐘 PostgresExpress20.303s (~)21.061s (~)0.758s21.89x
🐘 PostgresNitro20.372s (+32.8% 🔺)21.053s (+31.2% 🔺)0.682s21.90x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express18.895s (-5.9% 🟢)20.661s (-1.5%)1.766s21.00x
▲ VercelNext.js (Turbopack)21.076s (+8.2% 🔺)22.174s (+8.3% 🔺)1.098s21.12x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.866s27.050s0.185s31.00x
💻 LocalNext.js (Turbopack)27.305s28.051s0.746s31.02x
💻 LocalNitro27.454s (+2.6%)28.049s (+3.7%)0.596s31.02x
💻 LocalExpress27.557s (~)28.052s (~)0.495s31.03x
🌐 MongoDBNext.js (Turbopack)30.556s31.046s0.490s21.14x
🐘 PostgresNitro50.218s (+21.3% 🔺)51.134s (+21.4% 🔺)0.915s21.87x
🐘 PostgresExpress50.303s (~)50.630s (-1.0%)0.327s21.87x
🐘 PostgresNext.js (Turbopack)50.312s50.623s0.312s21.87x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express48.126s (-3.0%)48.969s (-5.3% 🟢)0.843s21.00x
▲ VercelNext.js (Turbopack)49.886s (-4.5%)51.786s (-3.7%)1.900s21.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)54.374s55.101s0.726s21.00x
💻 LocalNext.js (Turbopack)56.919s57.103s0.184s21.05x
💻 LocalNitro57.120s (+2.8%)58.098s (+3.6%)0.978s21.05x
💻 LocalExpress57.342s (~)58.106s (~)0.764s21.05x
🌐 MongoDBNext.js (Turbopack)61.157s61.592s0.435s21.12x
🐘 PostgresNext.js (Turbopack)91.740s92.206s0.466s11.69x
🐘 PostgresExpress100.159s (~)100.227s (-1.0%)0.068s11.84x
🐘 PostgresNitro100.479s (+26.0% 🔺)101.227s (+26.2% 🔺)0.748s11.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express101.457s (-0.6%)102.618s (-0.8%)1.161s11.00x
▲ VercelNext.js (Turbopack)104.877s (+1.8%)105.825s (+2.1%)0.948s11.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.254s2.006s0.753s151.00x
💻 LocalNitro1.405s (+4.5%)2.006s (~)0.601s151.12x
💻 LocalExpress1.416s (~)2.005s (~)0.589s151.13x
💻 LocalNext.js (Turbopack)1.432s2.005s0.573s151.14x
🐘 PostgresNext.js (Turbopack)2.095s2.474s0.379s131.67x
🌐 MongoDBNext.js (Turbopack)2.164s3.008s0.844s101.73x
🐘 PostgresExpress2.194s (-6.6% 🟢)3.014s (~)0.820s101.75x
🐘 PostgresNitro2.485s (+40.2% 🔺)3.014s (+45.0% 🔺)0.529s101.98x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.728s (+1.1%)3.889s (+4.9%)1.161s81.00x
▲ VercelExpress3.000s (+10.3% 🔺)4.204s (+9.7% 🔺)1.204s81.10x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)2.485s3.008s0.523s101.00x
💻 LocalNitro2.544s (+13.2% 🔺)3.007s (~)0.463s101.02x
💻 LocalExpress2.619s (+2.4%)3.007s (~)0.388s101.05x
💻 LocalNext.js (Turbopack)2.642s3.007s0.364s101.06x
🌐 MongoDBNext.js (Turbopack)4.767s5.178s0.411s61.92x
🐘 PostgresExpress8.884s (-0.5%)9.546s (+2.9%)0.661s43.58x
🐘 PostgresNitro9.960s (-10.4% 🟢)10.697s (-8.6% 🟢)0.737s34.01x
🐘 PostgresNext.js (Turbopack)12.648s13.037s0.389s35.09x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.552s (+9.2% 🔺)5.099s (+14.9% 🔺)1.547s61.00x
▲ VercelExpress3.700s (+17.3% 🔺)5.005s (+20.3% 🔺)1.305s71.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)4.067s4.725s0.658s71.00x
💻 LocalNitro7.071s (+19.5% 🔺)7.767s (+21.1% 🔺)0.696s41.74x
💻 LocalNext.js (Turbopack)7.627s8.016s0.390s41.88x
💻 LocalExpress7.708s (+5.2% 🔺)8.273s (+3.2%)0.565s41.90x
🌐 MongoDBNext.js (Turbopack)9.877s10.352s0.475s32.43x
🐘 PostgresNitro50.050s (-4.4%)50.119s (-5.7% 🟢)0.069s112.31x
🐘 PostgresExpress51.197s (+8.8% 🔺)52.128s (+8.4% 🔺)0.931s112.59x
🐘 PostgresNext.js (Turbopack)55.317s56.128s0.811s113.60x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.682s (-1.3%)5.309s (+5.1% 🔺)1.626s61.00x
▲ VercelExpress4.333s (+22.4% 🔺)5.927s (+17.6% 🔺)1.594s71.18x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.259s2.006s0.747s151.00x
💻 LocalNitro1.425s (+5.2% 🔺)2.004s (~)0.580s151.13x
💻 LocalExpress1.431s (~)2.006s (~)0.574s151.14x
💻 LocalNext.js (Turbopack)1.464s2.006s0.542s151.16x
🐘 PostgresNitro2.141s (+27.4% 🔺)2.831s (+36.3% 🔺)0.691s111.70x
🐘 PostgresExpress2.142s (+2.0%)2.741s (+5.6% 🔺)0.599s111.70x
🌐 MongoDBNext.js (Turbopack)2.169s3.008s0.839s101.72x
🐘 PostgresNext.js (Turbopack)2.308s2.741s0.432s111.83x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.631s (+1.9%)3.817s (-0.8%)1.186s81.00x
▲ VercelNext.js (Turbopack)2.777s (+2.8%)4.032s (+4.4%)1.254s81.06x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)2.493s3.008s0.515s101.00x
💻 LocalNitro2.635s (+9.4% 🔺)3.007s (~)0.372s101.06x
💻 LocalNext.js (Turbopack)2.722s3.008s0.285s101.09x
💻 LocalExpress2.837s (+3.1%)3.008s (~)0.170s101.14x
🌐 MongoDBNext.js (Turbopack)4.725s5.176s0.451s61.89x
🐘 PostgresExpress11.149s (+2.8%)11.698s (+2.9%)0.548s34.47x
🐘 PostgresNitro12.296s (+14.2% 🔺)13.039s (+14.7% 🔺)0.743s34.93x
🐘 PostgresNext.js (Turbopack)12.730s13.368s0.638s35.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.981s (-6.6% 🟢)4.089s (-0.8%)1.108s81.00x
▲ VercelNext.js (Turbopack)3.066s (-10.0% 🟢)3.923s (-15.5% 🟢)0.857s81.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)4.079s4.725s0.646s71.00x
💻 LocalNitro7.739s (+16.5% 🔺)8.016s (+14.3% 🔺)0.277s41.90x
💻 LocalNext.js (Turbopack)8.017s8.518s0.501s41.97x
💻 LocalExpress8.163s (+4.8%)9.023s (+12.5% 🔺)0.860s42.00x
🌐 MongoDBNext.js (Turbopack)9.734s10.348s0.614s32.39x
🐘 PostgresNitro52.679s (+7.4% 🔺)53.124s (+8.1% 🔺)0.445s112.91x
🐘 PostgresNext.js (Turbopack)54.508s55.127s0.619s113.36x
🐘 PostgresExpress54.742s (+4.8%)55.132s (+3.8%)0.390s113.42x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.188s (-6.3% 🟢)4.374s (-13.7% 🟢)1.186s71.00x
▲ VercelNext.js (Turbopack)3.623s (-14.2% 🟢)5.046s (-9.5% 🟢)1.422s61.14x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.147s1.001s0.011s1.016s0.869s101.00x
🌐 RedisNext.js (Turbopack)0.148s1.000s0.001s1.007s0.859s101.00x
💻 LocalNitro0.173s (+55.5% 🔺)1.002s (~)0.010s (+14.6% 🔺)1.015s (~)0.842s101.17x
💻 LocalExpress0.175s (~)1.002s (~)0.011s (+9.1% 🔺)1.016s (~)0.841s101.19x
🌐 MongoDBNext.js (Turbopack)0.496s0.951s0.001s1.009s0.513s103.37x
🐘 PostgresNext.js (Turbopack)1.459s1.650s0.001s2.012s0.553s109.90x
🐘 PostgresNitro2.144s (+68.7% 🔺)2.900s (+63.8% 🔺)0.001s (~)3.015s (+49.7% 🔺)0.871s1014.54x
🐘 PostgresExpress2.271s (+4.8%)2.771s (-3.6%)0.001s (-16.7% 🟢)3.015s (~)0.744s1015.41x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.396s (-6.6% 🟢)3.058s (-8.5% 🟢)0.167s (+8.2% 🔺)3.971s (-6.6% 🟢)1.575s101.00x
▲ VercelNext.js (Turbopack)2.609s (+8.1% 🔺)2.867s (-6.0% 🟢)0.198s (+87.7% 🔺)3.836s (-0.9%)1.227s101.09x
▲ VercelNitro⚠️missing-----

🔍 Observability: Express | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNitro7/12
🐘 PostgresNitro5/12
▲ VercelExpress8/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express💻 Local10/12
Next.js (Turbopack)🌐 Redis7/12
Nitro💻 Local12/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Starter: Community world (local development)
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

- Propagate Retry-After header as WorkflowAPIError.retryAfter on 429 responses
- Add withThrottleRetry wrapper for both workflow and step handlers
- Re-enqueue workflows on 5xx errors with exponential backoff (5s, 30s, 120min)
- Track serverErrorRetryCount in queue payload for retry budgeting
- Expose delaySeconds on QueueOptions interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
);
// Short wait: sleep in-process, then retry once
await new Promise((resolve) =>
setTimeout(resolve, retryAfterSeconds * 1000)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we account for function execution time limits specifically in the case of Vercel world? If the serverless fuction is already close to the end of it's limit and the workflow server throws a 429, adding a 10 sec sleep could potentially exceed the function execution limit and the function could get SIGKILLd midway.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

The workflow layer should never take much more than a few seconds, so I think it's highly unlikely that we'd run into timeouts, so I'm not too worried about this, but technically a concern

@karthikscale3

Copy link
Copy Markdown
Contributor

Rest of the code looks good except the one concern I have around function execution limits in the case of vercel world.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this looks solid — the 429 throttle retry and 5xx backoff are clean additions. The withThrottleRetry wrapper with the "safe to retry because 429 is pre-processing" invariant is a nice design. A few things to consider:

world-local doesn't support delaySeconds — The QueueOptions interface was updated and world-vercel passes it through, but world-local/src/queue.ts silently ignores opts?.delaySeconds. If a 5xx retry fires in local dev, the message will be re-enqueued but delivered immediately instead of after the intended backoff. Not a blocker since 5xx from world-local is uncommon, but could cause confusing behavior during local fault testing (which the PR description mentions wanting to add).

Step handler diff is mostly re-indentation — confirmed the actual logic changes are just wrapping in withThrottleRetry. The step handler does NOT get 5xx retry (only 429), which makes sense since the queue's built-in redelivery + step_started idempotency handles that.

@pranaygp

Copy link
Copy Markdown
Contributor

Inline review notes

Since GitHub's review API is having issues, posting inline comments here:


packages/core/src/runtime.ts:291delaySecondSteps = [5, 30, 7200]

The third backoff step is 7200s (2 hours). Is the expectation that persistent 5xx would trigger an alert/incident on the server side well before this fires? A brief comment explaining the rationale for these specific values would help future readers.


packages/core/src/runtime.ts:290-307serverErrorRetryCount lifecycle

The serverErrorRetryCount resets on each new workflow invocation (since the next queueMessage after a successful step won't include it). This means each invocation gets a fresh retry budget. That seems right for transient 5xx, but if the server is persistently down, each invocation will independently burn through all 3 retries before failing. Just want to confirm this is the intended behavior vs. tracking the count on the run itself.


packages/core/src/runtime/helpers.ts:425withThrottleRetry short-wait path

If the first in-process retry succeeds, great. But if it gets a different error (not 429), that error is thrown — which means the handler fails entirely rather than deferring to queue. This seems correct since a non-429 error should propagate, just want to confirm the intent.


packages/world-vercel/src/utils.ts:298Retry-After parsing

nit: Retry-After can also be an HTTP-date (RFC 9110). This only handles the delay-seconds format. Fine if you control the server, but worth a comment noting the assumption.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this is clean and well-structured. The two-tier 429 strategy (in-process for short waits, defer to queue for long waits) is nice. A few questions and concerns below.

*
* Safe to retry the entire handler because 429 is sent from server middleware
* before the request is processed — no server state has changed.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comment says "Safe to retry the entire handler because 429 is sent from server middleware before the request is processed — no server state has changed."

This holds if the 429 always hits on the first API call in the handler. But the handler makes multiple world API calls sequentially (e.g. runs.getevents.create(run_started) → replay → events.create(run_completed)). If a later call gets 429'd, the retry re-executes everything from the top.

For the workflow handler this is probably fine since replay is deterministic and events are idempotent.

For the step handler this is more concerning — the retry would re-execute user step code, which may not be idempotent. Is the assumption that the workflow-server's rate limiting middleware rejects at the connection level (so all calls in one handler invocation either succeed or all fail)? If so, worth documenting that assumption.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch, fixed

Comment threadpackages/core/src/runtime.ts Outdated
err.status >= 500
) {
const retryCount = serverErrorRetryCount ?? 0;
const delaySecondSteps = [5, 30, 7200]; // 5s, 30s, 120min

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The third step is 7200 seconds = 2 hours. Is that intentional? That's a very long time for a workflow to be stalled on a server error. If the 500 was truly transient, 2 hours feels excessive. If it's a sustained outage, you'd probably want to fail rather than retry after 2h when the server might have moved on.

Maybe something like [5, 30, 120] (5s, 30s, 2min) would be more practical?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Changed to [5, 30, 120]. Realistically if we keep getting 500s, it'd be nice to re-try the run much later so we have a change to incident-mitigate, but I think it's fine to fail runs instead too

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

cool. this was fully AI generated review btw. idk where it came up with 5s, 30, 120 - but I do agree i we should have some shorter ones before getting to 7200 anyway

}

const maxRetries = stepFn.maxRetries ?? DEFAULT_STEP_MAX_RETRIES;
return await withThrottleRetry(async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The step handler gets withThrottleRetry for 429s but no equivalent 5xx retry logic like the workflow handler has. If a world API call (e.g. events.create for step_started or step_completed) returns a 5xx, it'll be caught by the step's error handling and burn a step retry attempt — re-executing potentially expensive user code for what was a transient infrastructure error.

Is this intentional for this PR's scope, or should step handlers also get 5xx backoff retry?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

500s are handled via regular step re-try. 429s are only special-cased because they allow the step-retry mechanism to use the retry-after header instead of guessing at the retry

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

right but the comment is about not burning a full step attempt for transient 5xx. doesn't need to be addressed in this PR necessarily but good to think about. happy to move in any direction for now since they're all better than nothing and then come back to this in the future with real experience

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Makes sense that steps rely on their existing retry mechanism. One concern though: with withThrottleRetry removed, a 429 from the world API during step execution (e.g. on step_started or step_completed) will now burn a step retry attempt — potentially exhausting maxRetries without the user's code ever being at fault.

Ideally 429s should be retried transparently without consuming an attempt. A targeted retry around just the world API calls (not wrapping the user code execution) could thread that needle. Worth tracking as a follow-up.

idempotencyKey?: string;
headers?: Record<string, string>;
/** Delay message delivery by this many seconds */
delaySeconds?: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good that this was promoted from the vercel-specific type to the shared interface. Note that world-local currently ignores delaySeconds entirely — so 5xx retries in local dev will fire immediately instead of with backoff. Not a blocker but worth a follow-up or at minimum a comment.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Seems fine to ignore for local world, since there are no 429s

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

although we probably do actually want to support delaySeconds in local world anyway as we start using this option in queue more often in the future for more use cases. even if it's not implemented in this PR, I think we should leave an explicit comment that local world ignores this since it's a nuance. We should later have an e2e test that checks for this behaviour and would fail on local world without proper queue delaySeconds implementation (cc @TooTallNate )

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/src/runtime/step-handler.ts Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good! The removal of withThrottleRetry from the step handler was the right fix for the re-execution safety concern.

One thing worth tracking as a follow-up: with withThrottleRetry removed from steps, a 429 from the world API during step execution (e.g. on step_started or step_completed) will now burn a step retry attempt. This means a transient throttle from the server could exhaust a step's maxRetries without the user's code ever being at fault. Ideally 429s should be retried transparently without consuming an attempt — maybe a targeted retry around just the world API calls (not the user code execution) would thread that needle.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: [core] Handle 429 and 500 errors from worlds in runtime

Overall this is a solid approach to handling transient server errors. The 429 throttle retry with short/long path split is well-designed, and the 500 retry via re-enqueue with exponential backoff is a safe pattern. A few items to address below.

);
runtimeLogger.warn(
'Throttled again on retry, deferring to queue',
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redundant check: this is already inside if (WorkflowAPIError.is(err)), so the second WorkflowAPIError.is(err) on this line is always true. Should just be if (err.status === 429).

step = startResult.step;
} catch (err) {
if (WorkflowAPIError.is(err)) {
if (WorkflowAPIError.is(err) && err.status === 429) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The step handler has 429 handling here inside the step_started catch block, but unlike the workflow handler, it does not use withThrottleRetry and does not have the short-wait in-process retry path. This means a brief 429 (e.g., retryAfter=2s) will always defer to the queue rather than sleeping in-process. Is that intentional? If steps should also get the in-process retry for short waits, consider wrapping with withThrottleRetry similarly to the workflow handler.

if (retryAfterSeconds < 10) {
runtimeLogger.warn(
'Throttled by workflow-server (429), retrying in-process',
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: if retryAfter is undefined (header missing), this defaults to 1 second. For 429 responses without a Retry-After header, 1 second might be too aggressive. A slightly higher default (e.g., 3-5s) would be more conservative and reduce the chance of hitting the server again immediately while it's under load.

workflowStartedAt = +workflowRun.startedAt;

// At this point, the workflow is "running" and `startedAt` should
// definitely be set.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The entire workflow handler body is now wrapped in withThrottleRetry. This means if a 429 is thrown by any world call during workflow execution (e.g., world.runs.get, world.events.create, runWorkflow), the entire handler will be retried from scratch.

  1. If run_started event was already created successfully and then a later call throws 429, the retry will call world.runs.get again and find the run already running, which is handled correctly.
  2. However, if run_completed event creation throws 429, the retry would re-run the entire workflow (replay + execution). The workflow replay should be deterministic, but this could be expensive. Worth documenting this tradeoff in a comment.

});
// Use the run entity from the event response (no extra get call needed)
if (!result.run) {
if (workflowRun.status === 'pending') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The 500 retry re-enqueues with a new requestedAt and incremented serverErrorRetryCount. The traceCarrier is serialized fresh via serializeTraceCarrier(). If the trace context matters for correlating retries, this creates a new trace for each retry attempt. Consider whether preserving the original traceContext from the message would be preferable for observability (linking all retry attempts to the same parent trace).

WorldParseFormat,
PeerService,
RpcSystem,
RpcService,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note: the HTTP Retry-After header can also be an HTTP-date (e.g., Wed, 21 Oct 2015 07:28:00 GMT). The parseInt approach correctly returns NaN for date values, falling back to undefined. This is fine since the workflow-server likely only sends numeric values, but a comment noting this would be helpful.

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/src/runtime/helpers.ts Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
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.

3 participants

@VaguelySerious@karthikscale3@pranaygp
, '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

[core] Handle 429 and 500 errors from worlds in runtime - #966

Merged
VaguelySerious merged 12 commits into
mainfrom
peter/429-core
Feb 9, 2026
Merged

[core] Handle 429 and 500 errors from worlds in runtime#966
VaguelySerious merged 12 commits into
mainfrom
peter/429-core

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Feb 6, 2026

Copy link
Copy Markdown
Member

Uses retry-after header for 429 when provided.
500s are limited to 3x retry, doing exponential backoff.

Review with white space off for sanity.

Claude's suggestion for e2e tests, which I think we might do separately. I think we should make some sort of testbench for world errors and how the runtime reacts.

 3. E2E with a fault-injection endpoint (moderate effort, very targeted)
Add a test-only endpoint to the workbench app (or a middleware) that can be told to return 429/500 for the next N requests. Something like:
POST /e2e/inject-fault { status: 429, retryAfter: 3, count: 2 }
Then the e2e test would:
1. Inject a fault (e.g. "next 2 requests to workflow handler return 429 with Retry-After: 3")
2. Start a workflow
3. Assert it completes successfully despite the transient errors
4. Optionally check logs/traces for the retry events
This is the most surgical approach but requires adding test-only infrastructure. The world-local queue already makes HTTP requests to the app endpoints, so you could intercept at that
layer.

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@changeset-bot

changeset-botBot commented Feb 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9f75d33

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

This PR includes changesets to release 19 packages
NameType
@workflow/errorsPatch
@workflow/worldPatch
@workflow/world-vercelPatch
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
workflowPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/web-sharedPatch
@workflow/world-testingPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

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

@vercel

vercelBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production490038528
✅ 💻 Local Development418062480
✅ 📦 Local Production418062480
✅ 🐘 Local Postgres418062480
✅ 🪟 Windows450348
❌ 🌍 Community Worlds105429156
✅ 📋 Other123021144
Total2017422572316

❌ Failed Tests

🌍 Community Worlds (42 failed)

mongodb (1 failed):

  • webhookWorkflow

turso (41 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro4404
✅ example4404
✅ express4404
✅ fastify4404
✅ hono4404
✅ nextjs-turbopack4701
✅ nextjs-webpack4701
✅ nitro4404
✅ nuxt4404
✅ sveltekit4404
✅ vite4404
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack4503
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
❌ mongodb4413
✅ redis-dev300
✅ redis4503
✅ starter-dev300
✅ turso-dev300
❌ turso4413
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4107
✅ e2e-local-postgres-nest-stable4107
✅ e2e-local-prod-nest-stable4107

📋 View full workflow run

@github-actions

github-actionsBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro0.032s (+22.9% 🔺)1.005s (~)0.973s101.00x
💻 LocalExpress0.032s (-2.7%)1.005s (~)0.973s101.02x
💻 LocalNext.js (Turbopack)0.046s1.005s0.959s101.44x
🌐 RedisNext.js (Turbopack)0.051s1.005s0.953s101.62x
🐘 PostgresNitro0.106s (+13.5% 🔺)1.009s (~)0.903s103.36x
🌐 MongoDBNext.js (Turbopack)0.110s1.007s0.897s103.48x
🐘 PostgresExpress0.194s (-45.0% 🟢)1.021s (-2.1%)0.827s106.12x
🐘 PostgresNext.js (Turbopack)0.444s1.009s0.565s1014.01x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.784s (-7.5% 🟢)2.468s (-4.7%)1.684s101.00x
▲ VercelNext.js (Turbopack)0.786s (-14.1% 🟢)2.385s (-1.5%)1.599s101.00x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.105s2.006s0.901s101.00x
💻 LocalNitro1.105s (+2.8%)2.006s (~)0.901s101.00x
💻 LocalExpress1.108s (~)2.006s (~)0.898s101.00x
🌐 RedisNext.js (Turbopack)1.110s2.006s0.896s101.00x
🌐 MongoDBNext.js (Turbopack)1.300s2.007s0.707s101.18x
🐘 PostgresNext.js (Turbopack)1.907s2.113s0.206s101.73x
🐘 PostgresExpress2.460s (+10.6% 🔺)3.014s (~)0.554s102.23x
🐘 PostgresNitro2.500s (+27.1% 🔺)3.013s (+36.1% 🔺)0.513s102.26x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.957s (+12.5% 🔺)3.946s (+7.1% 🔺)0.988s101.00x
▲ VercelExpress3.215s (+22.0% 🔺)4.569s (+21.6% 🔺)1.354s101.09x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.722s11.022s0.300s31.00x
💻 LocalNext.js (Turbopack)10.745s11.023s0.278s31.00x
💻 LocalNitro10.838s (+2.4%)11.022s (~)0.184s31.01x
💻 LocalExpress10.839s (~)11.024s (~)0.185s31.01x
🌐 MongoDBNext.js (Turbopack)12.349s13.025s0.677s31.15x
🐘 PostgresNext.js (Turbopack)15.512s16.049s0.537s21.45x
🐘 PostgresExpress20.303s (~)21.061s (~)0.758s21.89x
🐘 PostgresNitro20.372s (+32.8% 🔺)21.053s (+31.2% 🔺)0.682s21.90x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express18.895s (-5.9% 🟢)20.661s (-1.5%)1.766s21.00x
▲ VercelNext.js (Turbopack)21.076s (+8.2% 🔺)22.174s (+8.3% 🔺)1.098s21.12x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.866s27.050s0.185s31.00x
💻 LocalNext.js (Turbopack)27.305s28.051s0.746s31.02x
💻 LocalNitro27.454s (+2.6%)28.049s (+3.7%)0.596s31.02x
💻 LocalExpress27.557s (~)28.052s (~)0.495s31.03x
🌐 MongoDBNext.js (Turbopack)30.556s31.046s0.490s21.14x
🐘 PostgresNitro50.218s (+21.3% 🔺)51.134s (+21.4% 🔺)0.915s21.87x
🐘 PostgresExpress50.303s (~)50.630s (-1.0%)0.327s21.87x
🐘 PostgresNext.js (Turbopack)50.312s50.623s0.312s21.87x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express48.126s (-3.0%)48.969s (-5.3% 🟢)0.843s21.00x
▲ VercelNext.js (Turbopack)49.886s (-4.5%)51.786s (-3.7%)1.900s21.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)54.374s55.101s0.726s21.00x
💻 LocalNext.js (Turbopack)56.919s57.103s0.184s21.05x
💻 LocalNitro57.120s (+2.8%)58.098s (+3.6%)0.978s21.05x
💻 LocalExpress57.342s (~)58.106s (~)0.764s21.05x
🌐 MongoDBNext.js (Turbopack)61.157s61.592s0.435s21.12x
🐘 PostgresNext.js (Turbopack)91.740s92.206s0.466s11.69x
🐘 PostgresExpress100.159s (~)100.227s (-1.0%)0.068s11.84x
🐘 PostgresNitro100.479s (+26.0% 🔺)101.227s (+26.2% 🔺)0.748s11.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express101.457s (-0.6%)102.618s (-0.8%)1.161s11.00x
▲ VercelNext.js (Turbopack)104.877s (+1.8%)105.825s (+2.1%)0.948s11.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.254s2.006s0.753s151.00x
💻 LocalNitro1.405s (+4.5%)2.006s (~)0.601s151.12x
💻 LocalExpress1.416s (~)2.005s (~)0.589s151.13x
💻 LocalNext.js (Turbopack)1.432s2.005s0.573s151.14x
🐘 PostgresNext.js (Turbopack)2.095s2.474s0.379s131.67x
🌐 MongoDBNext.js (Turbopack)2.164s3.008s0.844s101.73x
🐘 PostgresExpress2.194s (-6.6% 🟢)3.014s (~)0.820s101.75x
🐘 PostgresNitro2.485s (+40.2% 🔺)3.014s (+45.0% 🔺)0.529s101.98x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.728s (+1.1%)3.889s (+4.9%)1.161s81.00x
▲ VercelExpress3.000s (+10.3% 🔺)4.204s (+9.7% 🔺)1.204s81.10x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)2.485s3.008s0.523s101.00x
💻 LocalNitro2.544s (+13.2% 🔺)3.007s (~)0.463s101.02x
💻 LocalExpress2.619s (+2.4%)3.007s (~)0.388s101.05x
💻 LocalNext.js (Turbopack)2.642s3.007s0.364s101.06x
🌐 MongoDBNext.js (Turbopack)4.767s5.178s0.411s61.92x
🐘 PostgresExpress8.884s (-0.5%)9.546s (+2.9%)0.661s43.58x
🐘 PostgresNitro9.960s (-10.4% 🟢)10.697s (-8.6% 🟢)0.737s34.01x
🐘 PostgresNext.js (Turbopack)12.648s13.037s0.389s35.09x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.552s (+9.2% 🔺)5.099s (+14.9% 🔺)1.547s61.00x
▲ VercelExpress3.700s (+17.3% 🔺)5.005s (+20.3% 🔺)1.305s71.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)4.067s4.725s0.658s71.00x
💻 LocalNitro7.071s (+19.5% 🔺)7.767s (+21.1% 🔺)0.696s41.74x
💻 LocalNext.js (Turbopack)7.627s8.016s0.390s41.88x
💻 LocalExpress7.708s (+5.2% 🔺)8.273s (+3.2%)0.565s41.90x
🌐 MongoDBNext.js (Turbopack)9.877s10.352s0.475s32.43x
🐘 PostgresNitro50.050s (-4.4%)50.119s (-5.7% 🟢)0.069s112.31x
🐘 PostgresExpress51.197s (+8.8% 🔺)52.128s (+8.4% 🔺)0.931s112.59x
🐘 PostgresNext.js (Turbopack)55.317s56.128s0.811s113.60x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.682s (-1.3%)5.309s (+5.1% 🔺)1.626s61.00x
▲ VercelExpress4.333s (+22.4% 🔺)5.927s (+17.6% 🔺)1.594s71.18x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.259s2.006s0.747s151.00x
💻 LocalNitro1.425s (+5.2% 🔺)2.004s (~)0.580s151.13x
💻 LocalExpress1.431s (~)2.006s (~)0.574s151.14x
💻 LocalNext.js (Turbopack)1.464s2.006s0.542s151.16x
🐘 PostgresNitro2.141s (+27.4% 🔺)2.831s (+36.3% 🔺)0.691s111.70x
🐘 PostgresExpress2.142s (+2.0%)2.741s (+5.6% 🔺)0.599s111.70x
🌐 MongoDBNext.js (Turbopack)2.169s3.008s0.839s101.72x
🐘 PostgresNext.js (Turbopack)2.308s2.741s0.432s111.83x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.631s (+1.9%)3.817s (-0.8%)1.186s81.00x
▲ VercelNext.js (Turbopack)2.777s (+2.8%)4.032s (+4.4%)1.254s81.06x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)2.493s3.008s0.515s101.00x
💻 LocalNitro2.635s (+9.4% 🔺)3.007s (~)0.372s101.06x
💻 LocalNext.js (Turbopack)2.722s3.008s0.285s101.09x
💻 LocalExpress2.837s (+3.1%)3.008s (~)0.170s101.14x
🌐 MongoDBNext.js (Turbopack)4.725s5.176s0.451s61.89x
🐘 PostgresExpress11.149s (+2.8%)11.698s (+2.9%)0.548s34.47x
🐘 PostgresNitro12.296s (+14.2% 🔺)13.039s (+14.7% 🔺)0.743s34.93x
🐘 PostgresNext.js (Turbopack)12.730s13.368s0.638s35.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.981s (-6.6% 🟢)4.089s (-0.8%)1.108s81.00x
▲ VercelNext.js (Turbopack)3.066s (-10.0% 🟢)3.923s (-15.5% 🟢)0.857s81.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)4.079s4.725s0.646s71.00x
💻 LocalNitro7.739s (+16.5% 🔺)8.016s (+14.3% 🔺)0.277s41.90x
💻 LocalNext.js (Turbopack)8.017s8.518s0.501s41.97x
💻 LocalExpress8.163s (+4.8%)9.023s (+12.5% 🔺)0.860s42.00x
🌐 MongoDBNext.js (Turbopack)9.734s10.348s0.614s32.39x
🐘 PostgresNitro52.679s (+7.4% 🔺)53.124s (+8.1% 🔺)0.445s112.91x
🐘 PostgresNext.js (Turbopack)54.508s55.127s0.619s113.36x
🐘 PostgresExpress54.742s (+4.8%)55.132s (+3.8%)0.390s113.42x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.188s (-6.3% 🟢)4.374s (-13.7% 🟢)1.186s71.00x
▲ VercelNext.js (Turbopack)3.623s (-14.2% 🟢)5.046s (-9.5% 🟢)1.422s61.14x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.147s1.001s0.011s1.016s0.869s101.00x
🌐 RedisNext.js (Turbopack)0.148s1.000s0.001s1.007s0.859s101.00x
💻 LocalNitro0.173s (+55.5% 🔺)1.002s (~)0.010s (+14.6% 🔺)1.015s (~)0.842s101.17x
💻 LocalExpress0.175s (~)1.002s (~)0.011s (+9.1% 🔺)1.016s (~)0.841s101.19x
🌐 MongoDBNext.js (Turbopack)0.496s0.951s0.001s1.009s0.513s103.37x
🐘 PostgresNext.js (Turbopack)1.459s1.650s0.001s2.012s0.553s109.90x
🐘 PostgresNitro2.144s (+68.7% 🔺)2.900s (+63.8% 🔺)0.001s (~)3.015s (+49.7% 🔺)0.871s1014.54x
🐘 PostgresExpress2.271s (+4.8%)2.771s (-3.6%)0.001s (-16.7% 🟢)3.015s (~)0.744s1015.41x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.396s (-6.6% 🟢)3.058s (-8.5% 🟢)0.167s (+8.2% 🔺)3.971s (-6.6% 🟢)1.575s101.00x
▲ VercelNext.js (Turbopack)2.609s (+8.1% 🔺)2.867s (-6.0% 🟢)0.198s (+87.7% 🔺)3.836s (-0.9%)1.227s101.09x
▲ VercelNitro⚠️missing-----

🔍 Observability: Express | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNitro7/12
🐘 PostgresNitro5/12
▲ VercelExpress8/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express💻 Local10/12
Next.js (Turbopack)🌐 Redis7/12
Nitro💻 Local12/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Starter: Community world (local development)
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

- Propagate Retry-After header as WorkflowAPIError.retryAfter on 429 responses
- Add withThrottleRetry wrapper for both workflow and step handlers
- Re-enqueue workflows on 5xx errors with exponential backoff (5s, 30s, 120min)
- Track serverErrorRetryCount in queue payload for retry budgeting
- Expose delaySeconds on QueueOptions interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
);
// Short wait: sleep in-process, then retry once
await new Promise((resolve) =>
setTimeout(resolve, retryAfterSeconds * 1000)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we account for function execution time limits specifically in the case of Vercel world? If the serverless fuction is already close to the end of it's limit and the workflow server throws a 429, adding a 10 sec sleep could potentially exceed the function execution limit and the function could get SIGKILLd midway.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

The workflow layer should never take much more than a few seconds, so I think it's highly unlikely that we'd run into timeouts, so I'm not too worried about this, but technically a concern

@karthikscale3

Copy link
Copy Markdown
Contributor

Rest of the code looks good except the one concern I have around function execution limits in the case of vercel world.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this looks solid — the 429 throttle retry and 5xx backoff are clean additions. The withThrottleRetry wrapper with the "safe to retry because 429 is pre-processing" invariant is a nice design. A few things to consider:

world-local doesn't support delaySeconds — The QueueOptions interface was updated and world-vercel passes it through, but world-local/src/queue.ts silently ignores opts?.delaySeconds. If a 5xx retry fires in local dev, the message will be re-enqueued but delivered immediately instead of after the intended backoff. Not a blocker since 5xx from world-local is uncommon, but could cause confusing behavior during local fault testing (which the PR description mentions wanting to add).

Step handler diff is mostly re-indentation — confirmed the actual logic changes are just wrapping in withThrottleRetry. The step handler does NOT get 5xx retry (only 429), which makes sense since the queue's built-in redelivery + step_started idempotency handles that.

@pranaygp

Copy link
Copy Markdown
Contributor

Inline review notes

Since GitHub's review API is having issues, posting inline comments here:


packages/core/src/runtime.ts:291delaySecondSteps = [5, 30, 7200]

The third backoff step is 7200s (2 hours). Is the expectation that persistent 5xx would trigger an alert/incident on the server side well before this fires? A brief comment explaining the rationale for these specific values would help future readers.


packages/core/src/runtime.ts:290-307serverErrorRetryCount lifecycle

The serverErrorRetryCount resets on each new workflow invocation (since the next queueMessage after a successful step won't include it). This means each invocation gets a fresh retry budget. That seems right for transient 5xx, but if the server is persistently down, each invocation will independently burn through all 3 retries before failing. Just want to confirm this is the intended behavior vs. tracking the count on the run itself.


packages/core/src/runtime/helpers.ts:425withThrottleRetry short-wait path

If the first in-process retry succeeds, great. But if it gets a different error (not 429), that error is thrown — which means the handler fails entirely rather than deferring to queue. This seems correct since a non-429 error should propagate, just want to confirm the intent.


packages/world-vercel/src/utils.ts:298Retry-After parsing

nit: Retry-After can also be an HTTP-date (RFC 9110). This only handles the delay-seconds format. Fine if you control the server, but worth a comment noting the assumption.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this is clean and well-structured. The two-tier 429 strategy (in-process for short waits, defer to queue for long waits) is nice. A few questions and concerns below.

*
* Safe to retry the entire handler because 429 is sent from server middleware
* before the request is processed — no server state has changed.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comment says "Safe to retry the entire handler because 429 is sent from server middleware before the request is processed — no server state has changed."

This holds if the 429 always hits on the first API call in the handler. But the handler makes multiple world API calls sequentially (e.g. runs.getevents.create(run_started) → replay → events.create(run_completed)). If a later call gets 429'd, the retry re-executes everything from the top.

For the workflow handler this is probably fine since replay is deterministic and events are idempotent.

For the step handler this is more concerning — the retry would re-execute user step code, which may not be idempotent. Is the assumption that the workflow-server's rate limiting middleware rejects at the connection level (so all calls in one handler invocation either succeed or all fail)? If so, worth documenting that assumption.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch, fixed

Comment threadpackages/core/src/runtime.ts Outdated
err.status >= 500
) {
const retryCount = serverErrorRetryCount ?? 0;
const delaySecondSteps = [5, 30, 7200]; // 5s, 30s, 120min

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The third step is 7200 seconds = 2 hours. Is that intentional? That's a very long time for a workflow to be stalled on a server error. If the 500 was truly transient, 2 hours feels excessive. If it's a sustained outage, you'd probably want to fail rather than retry after 2h when the server might have moved on.

Maybe something like [5, 30, 120] (5s, 30s, 2min) would be more practical?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Changed to [5, 30, 120]. Realistically if we keep getting 500s, it'd be nice to re-try the run much later so we have a change to incident-mitigate, but I think it's fine to fail runs instead too

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

cool. this was fully AI generated review btw. idk where it came up with 5s, 30, 120 - but I do agree i we should have some shorter ones before getting to 7200 anyway

}

const maxRetries = stepFn.maxRetries ?? DEFAULT_STEP_MAX_RETRIES;
return await withThrottleRetry(async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The step handler gets withThrottleRetry for 429s but no equivalent 5xx retry logic like the workflow handler has. If a world API call (e.g. events.create for step_started or step_completed) returns a 5xx, it'll be caught by the step's error handling and burn a step retry attempt — re-executing potentially expensive user code for what was a transient infrastructure error.

Is this intentional for this PR's scope, or should step handlers also get 5xx backoff retry?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

500s are handled via regular step re-try. 429s are only special-cased because they allow the step-retry mechanism to use the retry-after header instead of guessing at the retry

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

right but the comment is about not burning a full step attempt for transient 5xx. doesn't need to be addressed in this PR necessarily but good to think about. happy to move in any direction for now since they're all better than nothing and then come back to this in the future with real experience

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Makes sense that steps rely on their existing retry mechanism. One concern though: with withThrottleRetry removed, a 429 from the world API during step execution (e.g. on step_started or step_completed) will now burn a step retry attempt — potentially exhausting maxRetries without the user's code ever being at fault.

Ideally 429s should be retried transparently without consuming an attempt. A targeted retry around just the world API calls (not wrapping the user code execution) could thread that needle. Worth tracking as a follow-up.

idempotencyKey?: string;
headers?: Record<string, string>;
/** Delay message delivery by this many seconds */
delaySeconds?: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good that this was promoted from the vercel-specific type to the shared interface. Note that world-local currently ignores delaySeconds entirely — so 5xx retries in local dev will fire immediately instead of with backoff. Not a blocker but worth a follow-up or at minimum a comment.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Seems fine to ignore for local world, since there are no 429s

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

although we probably do actually want to support delaySeconds in local world anyway as we start using this option in queue more often in the future for more use cases. even if it's not implemented in this PR, I think we should leave an explicit comment that local world ignores this since it's a nuance. We should later have an e2e test that checks for this behaviour and would fail on local world without proper queue delaySeconds implementation (cc @TooTallNate )

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/src/runtime/step-handler.ts Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good! The removal of withThrottleRetry from the step handler was the right fix for the re-execution safety concern.

One thing worth tracking as a follow-up: with withThrottleRetry removed from steps, a 429 from the world API during step execution (e.g. on step_started or step_completed) will now burn a step retry attempt. This means a transient throttle from the server could exhaust a step's maxRetries without the user's code ever being at fault. Ideally 429s should be retried transparently without consuming an attempt — maybe a targeted retry around just the world API calls (not the user code execution) would thread that needle.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: [core] Handle 429 and 500 errors from worlds in runtime

Overall this is a solid approach to handling transient server errors. The 429 throttle retry with short/long path split is well-designed, and the 500 retry via re-enqueue with exponential backoff is a safe pattern. A few items to address below.

);
runtimeLogger.warn(
'Throttled again on retry, deferring to queue',
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redundant check: this is already inside if (WorkflowAPIError.is(err)), so the second WorkflowAPIError.is(err) on this line is always true. Should just be if (err.status === 429).

step = startResult.step;
} catch (err) {
if (WorkflowAPIError.is(err)) {
if (WorkflowAPIError.is(err) && err.status === 429) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The step handler has 429 handling here inside the step_started catch block, but unlike the workflow handler, it does not use withThrottleRetry and does not have the short-wait in-process retry path. This means a brief 429 (e.g., retryAfter=2s) will always defer to the queue rather than sleeping in-process. Is that intentional? If steps should also get the in-process retry for short waits, consider wrapping with withThrottleRetry similarly to the workflow handler.

if (retryAfterSeconds < 10) {
runtimeLogger.warn(
'Throttled by workflow-server (429), retrying in-process',
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: if retryAfter is undefined (header missing), this defaults to 1 second. For 429 responses without a Retry-After header, 1 second might be too aggressive. A slightly higher default (e.g., 3-5s) would be more conservative and reduce the chance of hitting the server again immediately while it's under load.

workflowStartedAt = +workflowRun.startedAt;

// At this point, the workflow is "running" and `startedAt` should
// definitely be set.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The entire workflow handler body is now wrapped in withThrottleRetry. This means if a 429 is thrown by any world call during workflow execution (e.g., world.runs.get, world.events.create, runWorkflow), the entire handler will be retried from scratch.

  1. If run_started event was already created successfully and then a later call throws 429, the retry will call world.runs.get again and find the run already running, which is handled correctly.
  2. However, if run_completed event creation throws 429, the retry would re-run the entire workflow (replay + execution). The workflow replay should be deterministic, but this could be expensive. Worth documenting this tradeoff in a comment.

});
// Use the run entity from the event response (no extra get call needed)
if (!result.run) {
if (workflowRun.status === 'pending') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The 500 retry re-enqueues with a new requestedAt and incremented serverErrorRetryCount. The traceCarrier is serialized fresh via serializeTraceCarrier(). If the trace context matters for correlating retries, this creates a new trace for each retry attempt. Consider whether preserving the original traceContext from the message would be preferable for observability (linking all retry attempts to the same parent trace).

WorldParseFormat,
PeerService,
RpcSystem,
RpcService,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note: the HTTP Retry-After header can also be an HTTP-date (e.g., Wed, 21 Oct 2015 07:28:00 GMT). The parseInt approach correctly returns NaN for date values, falling back to undefined. This is fine since the workflow-server likely only sends numeric values, but a comment noting this would be helpful.

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/src/runtime/helpers.ts Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
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.

3 participants

@VaguelySerious@karthikscale3@pranaygp
, '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

[core] Handle 429 and 500 errors from worlds in runtime - #966

Merged
VaguelySerious merged 12 commits into
mainfrom
peter/429-core
Feb 9, 2026
Merged

[core] Handle 429 and 500 errors from worlds in runtime#966
VaguelySerious merged 12 commits into
mainfrom
peter/429-core

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Feb 6, 2026

Copy link
Copy Markdown
Member

Uses retry-after header for 429 when provided.
500s are limited to 3x retry, doing exponential backoff.

Review with white space off for sanity.

Claude's suggestion for e2e tests, which I think we might do separately. I think we should make some sort of testbench for world errors and how the runtime reacts.

 3. E2E with a fault-injection endpoint (moderate effort, very targeted)
Add a test-only endpoint to the workbench app (or a middleware) that can be told to return 429/500 for the next N requests. Something like:
POST /e2e/inject-fault { status: 429, retryAfter: 3, count: 2 }
Then the e2e test would:
1. Inject a fault (e.g. "next 2 requests to workflow handler return 429 with Retry-After: 3")
2. Start a workflow
3. Assert it completes successfully despite the transient errors
4. Optionally check logs/traces for the retry events
This is the most surgical approach but requires adding test-only infrastructure. The world-local queue already makes HTTP requests to the app endpoints, so you could intercept at that
layer.

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@changeset-bot

changeset-botBot commented Feb 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9f75d33

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

This PR includes changesets to release 19 packages
NameType
@workflow/errorsPatch
@workflow/worldPatch
@workflow/world-vercelPatch
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
workflowPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/web-sharedPatch
@workflow/world-testingPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

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

@vercel

vercelBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production490038528
✅ 💻 Local Development418062480
✅ 📦 Local Production418062480
✅ 🐘 Local Postgres418062480
✅ 🪟 Windows450348
❌ 🌍 Community Worlds105429156
✅ 📋 Other123021144
Total2017422572316

❌ Failed Tests

🌍 Community Worlds (42 failed)

mongodb (1 failed):

  • webhookWorkflow

turso (41 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro4404
✅ example4404
✅ express4404
✅ fastify4404
✅ hono4404
✅ nextjs-turbopack4701
✅ nextjs-webpack4701
✅ nitro4404
✅ nuxt4404
✅ sveltekit4404
✅ vite4404
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack4503
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
❌ mongodb4413
✅ redis-dev300
✅ redis4503
✅ starter-dev300
✅ turso-dev300
❌ turso4413
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4107
✅ e2e-local-postgres-nest-stable4107
✅ e2e-local-prod-nest-stable4107

📋 View full workflow run

@github-actions

github-actionsBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro0.032s (+22.9% 🔺)1.005s (~)0.973s101.00x
💻 LocalExpress0.032s (-2.7%)1.005s (~)0.973s101.02x
💻 LocalNext.js (Turbopack)0.046s1.005s0.959s101.44x
🌐 RedisNext.js (Turbopack)0.051s1.005s0.953s101.62x
🐘 PostgresNitro0.106s (+13.5% 🔺)1.009s (~)0.903s103.36x
🌐 MongoDBNext.js (Turbopack)0.110s1.007s0.897s103.48x
🐘 PostgresExpress0.194s (-45.0% 🟢)1.021s (-2.1%)0.827s106.12x
🐘 PostgresNext.js (Turbopack)0.444s1.009s0.565s1014.01x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.784s (-7.5% 🟢)2.468s (-4.7%)1.684s101.00x
▲ VercelNext.js (Turbopack)0.786s (-14.1% 🟢)2.385s (-1.5%)1.599s101.00x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.105s2.006s0.901s101.00x
💻 LocalNitro1.105s (+2.8%)2.006s (~)0.901s101.00x
💻 LocalExpress1.108s (~)2.006s (~)0.898s101.00x
🌐 RedisNext.js (Turbopack)1.110s2.006s0.896s101.00x
🌐 MongoDBNext.js (Turbopack)1.300s2.007s0.707s101.18x
🐘 PostgresNext.js (Turbopack)1.907s2.113s0.206s101.73x
🐘 PostgresExpress2.460s (+10.6% 🔺)3.014s (~)0.554s102.23x
🐘 PostgresNitro2.500s (+27.1% 🔺)3.013s (+36.1% 🔺)0.513s102.26x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.957s (+12.5% 🔺)3.946s (+7.1% 🔺)0.988s101.00x
▲ VercelExpress3.215s (+22.0% 🔺)4.569s (+21.6% 🔺)1.354s101.09x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.722s11.022s0.300s31.00x
💻 LocalNext.js (Turbopack)10.745s11.023s0.278s31.00x
💻 LocalNitro10.838s (+2.4%)11.022s (~)0.184s31.01x
💻 LocalExpress10.839s (~)11.024s (~)0.185s31.01x
🌐 MongoDBNext.js (Turbopack)12.349s13.025s0.677s31.15x
🐘 PostgresNext.js (Turbopack)15.512s16.049s0.537s21.45x
🐘 PostgresExpress20.303s (~)21.061s (~)0.758s21.89x
🐘 PostgresNitro20.372s (+32.8% 🔺)21.053s (+31.2% 🔺)0.682s21.90x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express18.895s (-5.9% 🟢)20.661s (-1.5%)1.766s21.00x
▲ VercelNext.js (Turbopack)21.076s (+8.2% 🔺)22.174s (+8.3% 🔺)1.098s21.12x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.866s27.050s0.185s31.00x
💻 LocalNext.js (Turbopack)27.305s28.051s0.746s31.02x
💻 LocalNitro27.454s (+2.6%)28.049s (+3.7%)0.596s31.02x
💻 LocalExpress27.557s (~)28.052s (~)0.495s31.03x
🌐 MongoDBNext.js (Turbopack)30.556s31.046s0.490s21.14x
🐘 PostgresNitro50.218s (+21.3% 🔺)51.134s (+21.4% 🔺)0.915s21.87x
🐘 PostgresExpress50.303s (~)50.630s (-1.0%)0.327s21.87x
🐘 PostgresNext.js (Turbopack)50.312s50.623s0.312s21.87x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express48.126s (-3.0%)48.969s (-5.3% 🟢)0.843s21.00x
▲ VercelNext.js (Turbopack)49.886s (-4.5%)51.786s (-3.7%)1.900s21.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)54.374s55.101s0.726s21.00x
💻 LocalNext.js (Turbopack)56.919s57.103s0.184s21.05x
💻 LocalNitro57.120s (+2.8%)58.098s (+3.6%)0.978s21.05x
💻 LocalExpress57.342s (~)58.106s (~)0.764s21.05x
🌐 MongoDBNext.js (Turbopack)61.157s61.592s0.435s21.12x
🐘 PostgresNext.js (Turbopack)91.740s92.206s0.466s11.69x
🐘 PostgresExpress100.159s (~)100.227s (-1.0%)0.068s11.84x
🐘 PostgresNitro100.479s (+26.0% 🔺)101.227s (+26.2% 🔺)0.748s11.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express101.457s (-0.6%)102.618s (-0.8%)1.161s11.00x
▲ VercelNext.js (Turbopack)104.877s (+1.8%)105.825s (+2.1%)0.948s11.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.254s2.006s0.753s151.00x
💻 LocalNitro1.405s (+4.5%)2.006s (~)0.601s151.12x
💻 LocalExpress1.416s (~)2.005s (~)0.589s151.13x
💻 LocalNext.js (Turbopack)1.432s2.005s0.573s151.14x
🐘 PostgresNext.js (Turbopack)2.095s2.474s0.379s131.67x
🌐 MongoDBNext.js (Turbopack)2.164s3.008s0.844s101.73x
🐘 PostgresExpress2.194s (-6.6% 🟢)3.014s (~)0.820s101.75x
🐘 PostgresNitro2.485s (+40.2% 🔺)3.014s (+45.0% 🔺)0.529s101.98x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.728s (+1.1%)3.889s (+4.9%)1.161s81.00x
▲ VercelExpress3.000s (+10.3% 🔺)4.204s (+9.7% 🔺)1.204s81.10x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)2.485s3.008s0.523s101.00x
💻 LocalNitro2.544s (+13.2% 🔺)3.007s (~)0.463s101.02x
💻 LocalExpress2.619s (+2.4%)3.007s (~)0.388s101.05x
💻 LocalNext.js (Turbopack)2.642s3.007s0.364s101.06x
🌐 MongoDBNext.js (Turbopack)4.767s5.178s0.411s61.92x
🐘 PostgresExpress8.884s (-0.5%)9.546s (+2.9%)0.661s43.58x
🐘 PostgresNitro9.960s (-10.4% 🟢)10.697s (-8.6% 🟢)0.737s34.01x
🐘 PostgresNext.js (Turbopack)12.648s13.037s0.389s35.09x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.552s (+9.2% 🔺)5.099s (+14.9% 🔺)1.547s61.00x
▲ VercelExpress3.700s (+17.3% 🔺)5.005s (+20.3% 🔺)1.305s71.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)4.067s4.725s0.658s71.00x
💻 LocalNitro7.071s (+19.5% 🔺)7.767s (+21.1% 🔺)0.696s41.74x
💻 LocalNext.js (Turbopack)7.627s8.016s0.390s41.88x
💻 LocalExpress7.708s (+5.2% 🔺)8.273s (+3.2%)0.565s41.90x
🌐 MongoDBNext.js (Turbopack)9.877s10.352s0.475s32.43x
🐘 PostgresNitro50.050s (-4.4%)50.119s (-5.7% 🟢)0.069s112.31x
🐘 PostgresExpress51.197s (+8.8% 🔺)52.128s (+8.4% 🔺)0.931s112.59x
🐘 PostgresNext.js (Turbopack)55.317s56.128s0.811s113.60x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.682s (-1.3%)5.309s (+5.1% 🔺)1.626s61.00x
▲ VercelExpress4.333s (+22.4% 🔺)5.927s (+17.6% 🔺)1.594s71.18x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.259s2.006s0.747s151.00x
💻 LocalNitro1.425s (+5.2% 🔺)2.004s (~)0.580s151.13x
💻 LocalExpress1.431s (~)2.006s (~)0.574s151.14x
💻 LocalNext.js (Turbopack)1.464s2.006s0.542s151.16x
🐘 PostgresNitro2.141s (+27.4% 🔺)2.831s (+36.3% 🔺)0.691s111.70x
🐘 PostgresExpress2.142s (+2.0%)2.741s (+5.6% 🔺)0.599s111.70x
🌐 MongoDBNext.js (Turbopack)2.169s3.008s0.839s101.72x
🐘 PostgresNext.js (Turbopack)2.308s2.741s0.432s111.83x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.631s (+1.9%)3.817s (-0.8%)1.186s81.00x
▲ VercelNext.js (Turbopack)2.777s (+2.8%)4.032s (+4.4%)1.254s81.06x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)2.493s3.008s0.515s101.00x
💻 LocalNitro2.635s (+9.4% 🔺)3.007s (~)0.372s101.06x
💻 LocalNext.js (Turbopack)2.722s3.008s0.285s101.09x
💻 LocalExpress2.837s (+3.1%)3.008s (~)0.170s101.14x
🌐 MongoDBNext.js (Turbopack)4.725s5.176s0.451s61.89x
🐘 PostgresExpress11.149s (+2.8%)11.698s (+2.9%)0.548s34.47x
🐘 PostgresNitro12.296s (+14.2% 🔺)13.039s (+14.7% 🔺)0.743s34.93x
🐘 PostgresNext.js (Turbopack)12.730s13.368s0.638s35.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.981s (-6.6% 🟢)4.089s (-0.8%)1.108s81.00x
▲ VercelNext.js (Turbopack)3.066s (-10.0% 🟢)3.923s (-15.5% 🟢)0.857s81.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)4.079s4.725s0.646s71.00x
💻 LocalNitro7.739s (+16.5% 🔺)8.016s (+14.3% 🔺)0.277s41.90x
💻 LocalNext.js (Turbopack)8.017s8.518s0.501s41.97x
💻 LocalExpress8.163s (+4.8%)9.023s (+12.5% 🔺)0.860s42.00x
🌐 MongoDBNext.js (Turbopack)9.734s10.348s0.614s32.39x
🐘 PostgresNitro52.679s (+7.4% 🔺)53.124s (+8.1% 🔺)0.445s112.91x
🐘 PostgresNext.js (Turbopack)54.508s55.127s0.619s113.36x
🐘 PostgresExpress54.742s (+4.8%)55.132s (+3.8%)0.390s113.42x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.188s (-6.3% 🟢)4.374s (-13.7% 🟢)1.186s71.00x
▲ VercelNext.js (Turbopack)3.623s (-14.2% 🟢)5.046s (-9.5% 🟢)1.422s61.14x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.147s1.001s0.011s1.016s0.869s101.00x
🌐 RedisNext.js (Turbopack)0.148s1.000s0.001s1.007s0.859s101.00x
💻 LocalNitro0.173s (+55.5% 🔺)1.002s (~)0.010s (+14.6% 🔺)1.015s (~)0.842s101.17x
💻 LocalExpress0.175s (~)1.002s (~)0.011s (+9.1% 🔺)1.016s (~)0.841s101.19x
🌐 MongoDBNext.js (Turbopack)0.496s0.951s0.001s1.009s0.513s103.37x
🐘 PostgresNext.js (Turbopack)1.459s1.650s0.001s2.012s0.553s109.90x
🐘 PostgresNitro2.144s (+68.7% 🔺)2.900s (+63.8% 🔺)0.001s (~)3.015s (+49.7% 🔺)0.871s1014.54x
🐘 PostgresExpress2.271s (+4.8%)2.771s (-3.6%)0.001s (-16.7% 🟢)3.015s (~)0.744s1015.41x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.396s (-6.6% 🟢)3.058s (-8.5% 🟢)0.167s (+8.2% 🔺)3.971s (-6.6% 🟢)1.575s101.00x
▲ VercelNext.js (Turbopack)2.609s (+8.1% 🔺)2.867s (-6.0% 🟢)0.198s (+87.7% 🔺)3.836s (-0.9%)1.227s101.09x
▲ VercelNitro⚠️missing-----

🔍 Observability: Express | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNitro7/12
🐘 PostgresNitro5/12
▲ VercelExpress8/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express💻 Local10/12
Next.js (Turbopack)🌐 Redis7/12
Nitro💻 Local12/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Starter: Community world (local development)
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

- Propagate Retry-After header as WorkflowAPIError.retryAfter on 429 responses
- Add withThrottleRetry wrapper for both workflow and step handlers
- Re-enqueue workflows on 5xx errors with exponential backoff (5s, 30s, 120min)
- Track serverErrorRetryCount in queue payload for retry budgeting
- Expose delaySeconds on QueueOptions interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
);
// Short wait: sleep in-process, then retry once
await new Promise((resolve) =>
setTimeout(resolve, retryAfterSeconds * 1000)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we account for function execution time limits specifically in the case of Vercel world? If the serverless fuction is already close to the end of it's limit and the workflow server throws a 429, adding a 10 sec sleep could potentially exceed the function execution limit and the function could get SIGKILLd midway.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

The workflow layer should never take much more than a few seconds, so I think it's highly unlikely that we'd run into timeouts, so I'm not too worried about this, but technically a concern

@karthikscale3

Copy link
Copy Markdown
Contributor

Rest of the code looks good except the one concern I have around function execution limits in the case of vercel world.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this looks solid — the 429 throttle retry and 5xx backoff are clean additions. The withThrottleRetry wrapper with the "safe to retry because 429 is pre-processing" invariant is a nice design. A few things to consider:

world-local doesn't support delaySeconds — The QueueOptions interface was updated and world-vercel passes it through, but world-local/src/queue.ts silently ignores opts?.delaySeconds. If a 5xx retry fires in local dev, the message will be re-enqueued but delivered immediately instead of after the intended backoff. Not a blocker since 5xx from world-local is uncommon, but could cause confusing behavior during local fault testing (which the PR description mentions wanting to add).

Step handler diff is mostly re-indentation — confirmed the actual logic changes are just wrapping in withThrottleRetry. The step handler does NOT get 5xx retry (only 429), which makes sense since the queue's built-in redelivery + step_started idempotency handles that.

@pranaygp

Copy link
Copy Markdown
Contributor

Inline review notes

Since GitHub's review API is having issues, posting inline comments here:


packages/core/src/runtime.ts:291delaySecondSteps = [5, 30, 7200]

The third backoff step is 7200s (2 hours). Is the expectation that persistent 5xx would trigger an alert/incident on the server side well before this fires? A brief comment explaining the rationale for these specific values would help future readers.


packages/core/src/runtime.ts:290-307serverErrorRetryCount lifecycle

The serverErrorRetryCount resets on each new workflow invocation (since the next queueMessage after a successful step won't include it). This means each invocation gets a fresh retry budget. That seems right for transient 5xx, but if the server is persistently down, each invocation will independently burn through all 3 retries before failing. Just want to confirm this is the intended behavior vs. tracking the count on the run itself.


packages/core/src/runtime/helpers.ts:425withThrottleRetry short-wait path

If the first in-process retry succeeds, great. But if it gets a different error (not 429), that error is thrown — which means the handler fails entirely rather than deferring to queue. This seems correct since a non-429 error should propagate, just want to confirm the intent.


packages/world-vercel/src/utils.ts:298Retry-After parsing

nit: Retry-After can also be an HTTP-date (RFC 9110). This only handles the delay-seconds format. Fine if you control the server, but worth a comment noting the assumption.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this is clean and well-structured. The two-tier 429 strategy (in-process for short waits, defer to queue for long waits) is nice. A few questions and concerns below.

*
* Safe to retry the entire handler because 429 is sent from server middleware
* before the request is processed — no server state has changed.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comment says "Safe to retry the entire handler because 429 is sent from server middleware before the request is processed — no server state has changed."

This holds if the 429 always hits on the first API call in the handler. But the handler makes multiple world API calls sequentially (e.g. runs.getevents.create(run_started) → replay → events.create(run_completed)). If a later call gets 429'd, the retry re-executes everything from the top.

For the workflow handler this is probably fine since replay is deterministic and events are idempotent.

For the step handler this is more concerning — the retry would re-execute user step code, which may not be idempotent. Is the assumption that the workflow-server's rate limiting middleware rejects at the connection level (so all calls in one handler invocation either succeed or all fail)? If so, worth documenting that assumption.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch, fixed

Comment threadpackages/core/src/runtime.ts Outdated
err.status >= 500
) {
const retryCount = serverErrorRetryCount ?? 0;
const delaySecondSteps = [5, 30, 7200]; // 5s, 30s, 120min

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The third step is 7200 seconds = 2 hours. Is that intentional? That's a very long time for a workflow to be stalled on a server error. If the 500 was truly transient, 2 hours feels excessive. If it's a sustained outage, you'd probably want to fail rather than retry after 2h when the server might have moved on.

Maybe something like [5, 30, 120] (5s, 30s, 2min) would be more practical?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Changed to [5, 30, 120]. Realistically if we keep getting 500s, it'd be nice to re-try the run much later so we have a change to incident-mitigate, but I think it's fine to fail runs instead too

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

cool. this was fully AI generated review btw. idk where it came up with 5s, 30, 120 - but I do agree i we should have some shorter ones before getting to 7200 anyway

}

const maxRetries = stepFn.maxRetries ?? DEFAULT_STEP_MAX_RETRIES;
return await withThrottleRetry(async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The step handler gets withThrottleRetry for 429s but no equivalent 5xx retry logic like the workflow handler has. If a world API call (e.g. events.create for step_started or step_completed) returns a 5xx, it'll be caught by the step's error handling and burn a step retry attempt — re-executing potentially expensive user code for what was a transient infrastructure error.

Is this intentional for this PR's scope, or should step handlers also get 5xx backoff retry?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

500s are handled via regular step re-try. 429s are only special-cased because they allow the step-retry mechanism to use the retry-after header instead of guessing at the retry

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

right but the comment is about not burning a full step attempt for transient 5xx. doesn't need to be addressed in this PR necessarily but good to think about. happy to move in any direction for now since they're all better than nothing and then come back to this in the future with real experience

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Makes sense that steps rely on their existing retry mechanism. One concern though: with withThrottleRetry removed, a 429 from the world API during step execution (e.g. on step_started or step_completed) will now burn a step retry attempt — potentially exhausting maxRetries without the user's code ever being at fault.

Ideally 429s should be retried transparently without consuming an attempt. A targeted retry around just the world API calls (not wrapping the user code execution) could thread that needle. Worth tracking as a follow-up.

idempotencyKey?: string;
headers?: Record<string, string>;
/** Delay message delivery by this many seconds */
delaySeconds?: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good that this was promoted from the vercel-specific type to the shared interface. Note that world-local currently ignores delaySeconds entirely — so 5xx retries in local dev will fire immediately instead of with backoff. Not a blocker but worth a follow-up or at minimum a comment.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Seems fine to ignore for local world, since there are no 429s

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

although we probably do actually want to support delaySeconds in local world anyway as we start using this option in queue more often in the future for more use cases. even if it's not implemented in this PR, I think we should leave an explicit comment that local world ignores this since it's a nuance. We should later have an e2e test that checks for this behaviour and would fail on local world without proper queue delaySeconds implementation (cc @TooTallNate )

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/src/runtime/step-handler.ts Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good! The removal of withThrottleRetry from the step handler was the right fix for the re-execution safety concern.

One thing worth tracking as a follow-up: with withThrottleRetry removed from steps, a 429 from the world API during step execution (e.g. on step_started or step_completed) will now burn a step retry attempt. This means a transient throttle from the server could exhaust a step's maxRetries without the user's code ever being at fault. Ideally 429s should be retried transparently without consuming an attempt — maybe a targeted retry around just the world API calls (not the user code execution) would thread that needle.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: [core] Handle 429 and 500 errors from worlds in runtime

Overall this is a solid approach to handling transient server errors. The 429 throttle retry with short/long path split is well-designed, and the 500 retry via re-enqueue with exponential backoff is a safe pattern. A few items to address below.

);
runtimeLogger.warn(
'Throttled again on retry, deferring to queue',
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redundant check: this is already inside if (WorkflowAPIError.is(err)), so the second WorkflowAPIError.is(err) on this line is always true. Should just be if (err.status === 429).

step = startResult.step;
} catch (err) {
if (WorkflowAPIError.is(err)) {
if (WorkflowAPIError.is(err) && err.status === 429) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The step handler has 429 handling here inside the step_started catch block, but unlike the workflow handler, it does not use withThrottleRetry and does not have the short-wait in-process retry path. This means a brief 429 (e.g., retryAfter=2s) will always defer to the queue rather than sleeping in-process. Is that intentional? If steps should also get the in-process retry for short waits, consider wrapping with withThrottleRetry similarly to the workflow handler.

if (retryAfterSeconds < 10) {
runtimeLogger.warn(
'Throttled by workflow-server (429), retrying in-process',
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: if retryAfter is undefined (header missing), this defaults to 1 second. For 429 responses without a Retry-After header, 1 second might be too aggressive. A slightly higher default (e.g., 3-5s) would be more conservative and reduce the chance of hitting the server again immediately while it's under load.

workflowStartedAt = +workflowRun.startedAt;

// At this point, the workflow is "running" and `startedAt` should
// definitely be set.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The entire workflow handler body is now wrapped in withThrottleRetry. This means if a 429 is thrown by any world call during workflow execution (e.g., world.runs.get, world.events.create, runWorkflow), the entire handler will be retried from scratch.

  1. If run_started event was already created successfully and then a later call throws 429, the retry will call world.runs.get again and find the run already running, which is handled correctly.
  2. However, if run_completed event creation throws 429, the retry would re-run the entire workflow (replay + execution). The workflow replay should be deterministic, but this could be expensive. Worth documenting this tradeoff in a comment.

});
// Use the run entity from the event response (no extra get call needed)
if (!result.run) {
if (workflowRun.status === 'pending') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The 500 retry re-enqueues with a new requestedAt and incremented serverErrorRetryCount. The traceCarrier is serialized fresh via serializeTraceCarrier(). If the trace context matters for correlating retries, this creates a new trace for each retry attempt. Consider whether preserving the original traceContext from the message would be preferable for observability (linking all retry attempts to the same parent trace).

WorldParseFormat,
PeerService,
RpcSystem,
RpcService,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note: the HTTP Retry-After header can also be an HTTP-date (e.g., Wed, 21 Oct 2015 07:28:00 GMT). The parseInt approach correctly returns NaN for date values, falling back to undefined. This is fine since the workflow-server likely only sends numeric values, but a comment noting this would be helpful.

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/src/runtime/helpers.ts Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
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.

3 participants

@VaguelySerious@karthikscale3@pranaygp
, '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

[core] Handle 429 and 500 errors from worlds in runtime - #966

Merged
VaguelySerious merged 12 commits into
mainfrom
peter/429-core
Feb 9, 2026
Merged

[core] Handle 429 and 500 errors from worlds in runtime#966
VaguelySerious merged 12 commits into
mainfrom
peter/429-core

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Feb 6, 2026

Copy link
Copy Markdown
Member

Uses retry-after header for 429 when provided.
500s are limited to 3x retry, doing exponential backoff.

Review with white space off for sanity.

Claude's suggestion for e2e tests, which I think we might do separately. I think we should make some sort of testbench for world errors and how the runtime reacts.

 3. E2E with a fault-injection endpoint (moderate effort, very targeted)
Add a test-only endpoint to the workbench app (or a middleware) that can be told to return 429/500 for the next N requests. Something like:
POST /e2e/inject-fault { status: 429, retryAfter: 3, count: 2 }
Then the e2e test would:
1. Inject a fault (e.g. "next 2 requests to workflow handler return 429 with Retry-After: 3")
2. Start a workflow
3. Assert it completes successfully despite the transient errors
4. Optionally check logs/traces for the retry events
This is the most surgical approach but requires adding test-only infrastructure. The world-local queue already makes HTTP requests to the app endpoints, so you could intercept at that
layer.

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@changeset-bot

changeset-botBot commented Feb 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9f75d33

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

This PR includes changesets to release 19 packages
NameType
@workflow/errorsPatch
@workflow/worldPatch
@workflow/world-vercelPatch
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
workflowPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/web-sharedPatch
@workflow/world-testingPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

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

@vercel

vercelBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production490038528
✅ 💻 Local Development418062480
✅ 📦 Local Production418062480
✅ 🐘 Local Postgres418062480
✅ 🪟 Windows450348
❌ 🌍 Community Worlds105429156
✅ 📋 Other123021144
Total2017422572316

❌ Failed Tests

🌍 Community Worlds (42 failed)

mongodb (1 failed):

  • webhookWorkflow

turso (41 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro4404
✅ example4404
✅ express4404
✅ fastify4404
✅ hono4404
✅ nextjs-turbopack4701
✅ nextjs-webpack4701
✅ nitro4404
✅ nuxt4404
✅ sveltekit4404
✅ vite4404
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4107
✅ express-stable4107
✅ fastify-stable4107
✅ hono-stable4107
✅ nextjs-turbopack-stable4503
✅ nextjs-webpack-stable4503
✅ nitro-stable4107
✅ nuxt-stable4107
✅ sveltekit-stable4107
✅ vite-stable4107
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack4503
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
❌ mongodb4413
✅ redis-dev300
✅ redis4503
✅ starter-dev300
✅ turso-dev300
❌ turso4413
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4107
✅ e2e-local-postgres-nest-stable4107
✅ e2e-local-prod-nest-stable4107

📋 View full workflow run

@github-actions

github-actionsBot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro0.032s (+22.9% 🔺)1.005s (~)0.973s101.00x
💻 LocalExpress0.032s (-2.7%)1.005s (~)0.973s101.02x
💻 LocalNext.js (Turbopack)0.046s1.005s0.959s101.44x
🌐 RedisNext.js (Turbopack)0.051s1.005s0.953s101.62x
🐘 PostgresNitro0.106s (+13.5% 🔺)1.009s (~)0.903s103.36x
🌐 MongoDBNext.js (Turbopack)0.110s1.007s0.897s103.48x
🐘 PostgresExpress0.194s (-45.0% 🟢)1.021s (-2.1%)0.827s106.12x
🐘 PostgresNext.js (Turbopack)0.444s1.009s0.565s1014.01x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.784s (-7.5% 🟢)2.468s (-4.7%)1.684s101.00x
▲ VercelNext.js (Turbopack)0.786s (-14.1% 🟢)2.385s (-1.5%)1.599s101.00x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.105s2.006s0.901s101.00x
💻 LocalNitro1.105s (+2.8%)2.006s (~)0.901s101.00x
💻 LocalExpress1.108s (~)2.006s (~)0.898s101.00x
🌐 RedisNext.js (Turbopack)1.110s2.006s0.896s101.00x
🌐 MongoDBNext.js (Turbopack)1.300s2.007s0.707s101.18x
🐘 PostgresNext.js (Turbopack)1.907s2.113s0.206s101.73x
🐘 PostgresExpress2.460s (+10.6% 🔺)3.014s (~)0.554s102.23x
🐘 PostgresNitro2.500s (+27.1% 🔺)3.013s (+36.1% 🔺)0.513s102.26x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.957s (+12.5% 🔺)3.946s (+7.1% 🔺)0.988s101.00x
▲ VercelExpress3.215s (+22.0% 🔺)4.569s (+21.6% 🔺)1.354s101.09x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.722s11.022s0.300s31.00x
💻 LocalNext.js (Turbopack)10.745s11.023s0.278s31.00x
💻 LocalNitro10.838s (+2.4%)11.022s (~)0.184s31.01x
💻 LocalExpress10.839s (~)11.024s (~)0.185s31.01x
🌐 MongoDBNext.js (Turbopack)12.349s13.025s0.677s31.15x
🐘 PostgresNext.js (Turbopack)15.512s16.049s0.537s21.45x
🐘 PostgresExpress20.303s (~)21.061s (~)0.758s21.89x
🐘 PostgresNitro20.372s (+32.8% 🔺)21.053s (+31.2% 🔺)0.682s21.90x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express18.895s (-5.9% 🟢)20.661s (-1.5%)1.766s21.00x
▲ VercelNext.js (Turbopack)21.076s (+8.2% 🔺)22.174s (+8.3% 🔺)1.098s21.12x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.866s27.050s0.185s31.00x
💻 LocalNext.js (Turbopack)27.305s28.051s0.746s31.02x
💻 LocalNitro27.454s (+2.6%)28.049s (+3.7%)0.596s31.02x
💻 LocalExpress27.557s (~)28.052s (~)0.495s31.03x
🌐 MongoDBNext.js (Turbopack)30.556s31.046s0.490s21.14x
🐘 PostgresNitro50.218s (+21.3% 🔺)51.134s (+21.4% 🔺)0.915s21.87x
🐘 PostgresExpress50.303s (~)50.630s (-1.0%)0.327s21.87x
🐘 PostgresNext.js (Turbopack)50.312s50.623s0.312s21.87x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express48.126s (-3.0%)48.969s (-5.3% 🟢)0.843s21.00x
▲ VercelNext.js (Turbopack)49.886s (-4.5%)51.786s (-3.7%)1.900s21.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)54.374s55.101s0.726s21.00x
💻 LocalNext.js (Turbopack)56.919s57.103s0.184s21.05x
💻 LocalNitro57.120s (+2.8%)58.098s (+3.6%)0.978s21.05x
💻 LocalExpress57.342s (~)58.106s (~)0.764s21.05x
🌐 MongoDBNext.js (Turbopack)61.157s61.592s0.435s21.12x
🐘 PostgresNext.js (Turbopack)91.740s92.206s0.466s11.69x
🐘 PostgresExpress100.159s (~)100.227s (-1.0%)0.068s11.84x
🐘 PostgresNitro100.479s (+26.0% 🔺)101.227s (+26.2% 🔺)0.748s11.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express101.457s (-0.6%)102.618s (-0.8%)1.161s11.00x
▲ VercelNext.js (Turbopack)104.877s (+1.8%)105.825s (+2.1%)0.948s11.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.254s2.006s0.753s151.00x
💻 LocalNitro1.405s (+4.5%)2.006s (~)0.601s151.12x
💻 LocalExpress1.416s (~)2.005s (~)0.589s151.13x
💻 LocalNext.js (Turbopack)1.432s2.005s0.573s151.14x
🐘 PostgresNext.js (Turbopack)2.095s2.474s0.379s131.67x
🌐 MongoDBNext.js (Turbopack)2.164s3.008s0.844s101.73x
🐘 PostgresExpress2.194s (-6.6% 🟢)3.014s (~)0.820s101.75x
🐘 PostgresNitro2.485s (+40.2% 🔺)3.014s (+45.0% 🔺)0.529s101.98x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.728s (+1.1%)3.889s (+4.9%)1.161s81.00x
▲ VercelExpress3.000s (+10.3% 🔺)4.204s (+9.7% 🔺)1.204s81.10x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)2.485s3.008s0.523s101.00x
💻 LocalNitro2.544s (+13.2% 🔺)3.007s (~)0.463s101.02x
💻 LocalExpress2.619s (+2.4%)3.007s (~)0.388s101.05x
💻 LocalNext.js (Turbopack)2.642s3.007s0.364s101.06x
🌐 MongoDBNext.js (Turbopack)4.767s5.178s0.411s61.92x
🐘 PostgresExpress8.884s (-0.5%)9.546s (+2.9%)0.661s43.58x
🐘 PostgresNitro9.960s (-10.4% 🟢)10.697s (-8.6% 🟢)0.737s34.01x
🐘 PostgresNext.js (Turbopack)12.648s13.037s0.389s35.09x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.552s (+9.2% 🔺)5.099s (+14.9% 🔺)1.547s61.00x
▲ VercelExpress3.700s (+17.3% 🔺)5.005s (+20.3% 🔺)1.305s71.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)4.067s4.725s0.658s71.00x
💻 LocalNitro7.071s (+19.5% 🔺)7.767s (+21.1% 🔺)0.696s41.74x
💻 LocalNext.js (Turbopack)7.627s8.016s0.390s41.88x
💻 LocalExpress7.708s (+5.2% 🔺)8.273s (+3.2%)0.565s41.90x
🌐 MongoDBNext.js (Turbopack)9.877s10.352s0.475s32.43x
🐘 PostgresNitro50.050s (-4.4%)50.119s (-5.7% 🟢)0.069s112.31x
🐘 PostgresExpress51.197s (+8.8% 🔺)52.128s (+8.4% 🔺)0.931s112.59x
🐘 PostgresNext.js (Turbopack)55.317s56.128s0.811s113.60x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.682s (-1.3%)5.309s (+5.1% 🔺)1.626s61.00x
▲ VercelExpress4.333s (+22.4% 🔺)5.927s (+17.6% 🔺)1.594s71.18x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.259s2.006s0.747s151.00x
💻 LocalNitro1.425s (+5.2% 🔺)2.004s (~)0.580s151.13x
💻 LocalExpress1.431s (~)2.006s (~)0.574s151.14x
💻 LocalNext.js (Turbopack)1.464s2.006s0.542s151.16x
🐘 PostgresNitro2.141s (+27.4% 🔺)2.831s (+36.3% 🔺)0.691s111.70x
🐘 PostgresExpress2.142s (+2.0%)2.741s (+5.6% 🔺)0.599s111.70x
🌐 MongoDBNext.js (Turbopack)2.169s3.008s0.839s101.72x
🐘 PostgresNext.js (Turbopack)2.308s2.741s0.432s111.83x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.631s (+1.9%)3.817s (-0.8%)1.186s81.00x
▲ VercelNext.js (Turbopack)2.777s (+2.8%)4.032s (+4.4%)1.254s81.06x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)2.493s3.008s0.515s101.00x
💻 LocalNitro2.635s (+9.4% 🔺)3.007s (~)0.372s101.06x
💻 LocalNext.js (Turbopack)2.722s3.008s0.285s101.09x
💻 LocalExpress2.837s (+3.1%)3.008s (~)0.170s101.14x
🌐 MongoDBNext.js (Turbopack)4.725s5.176s0.451s61.89x
🐘 PostgresExpress11.149s (+2.8%)11.698s (+2.9%)0.548s34.47x
🐘 PostgresNitro12.296s (+14.2% 🔺)13.039s (+14.7% 🔺)0.743s34.93x
🐘 PostgresNext.js (Turbopack)12.730s13.368s0.638s35.11x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.981s (-6.6% 🟢)4.089s (-0.8%)1.108s81.00x
▲ VercelNext.js (Turbopack)3.066s (-10.0% 🟢)3.923s (-15.5% 🟢)0.857s81.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)4.079s4.725s0.646s71.00x
💻 LocalNitro7.739s (+16.5% 🔺)8.016s (+14.3% 🔺)0.277s41.90x
💻 LocalNext.js (Turbopack)8.017s8.518s0.501s41.97x
💻 LocalExpress8.163s (+4.8%)9.023s (+12.5% 🔺)0.860s42.00x
🌐 MongoDBNext.js (Turbopack)9.734s10.348s0.614s32.39x
🐘 PostgresNitro52.679s (+7.4% 🔺)53.124s (+8.1% 🔺)0.445s112.91x
🐘 PostgresNext.js (Turbopack)54.508s55.127s0.619s113.36x
🐘 PostgresExpress54.742s (+4.8%)55.132s (+3.8%)0.390s113.42x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.188s (-6.3% 🟢)4.374s (-13.7% 🟢)1.186s71.00x
▲ VercelNext.js (Turbopack)3.623s (-14.2% 🟢)5.046s (-9.5% 🟢)1.422s61.14x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.147s1.001s0.011s1.016s0.869s101.00x
🌐 RedisNext.js (Turbopack)0.148s1.000s0.001s1.007s0.859s101.00x
💻 LocalNitro0.173s (+55.5% 🔺)1.002s (~)0.010s (+14.6% 🔺)1.015s (~)0.842s101.17x
💻 LocalExpress0.175s (~)1.002s (~)0.011s (+9.1% 🔺)1.016s (~)0.841s101.19x
🌐 MongoDBNext.js (Turbopack)0.496s0.951s0.001s1.009s0.513s103.37x
🐘 PostgresNext.js (Turbopack)1.459s1.650s0.001s2.012s0.553s109.90x
🐘 PostgresNitro2.144s (+68.7% 🔺)2.900s (+63.8% 🔺)0.001s (~)3.015s (+49.7% 🔺)0.871s1014.54x
🐘 PostgresExpress2.271s (+4.8%)2.771s (-3.6%)0.001s (-16.7% 🟢)3.015s (~)0.744s1015.41x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.396s (-6.6% 🟢)3.058s (-8.5% 🟢)0.167s (+8.2% 🔺)3.971s (-6.6% 🟢)1.575s101.00x
▲ VercelNext.js (Turbopack)2.609s (+8.1% 🔺)2.867s (-6.0% 🟢)0.198s (+87.7% 🔺)3.836s (-0.9%)1.227s101.09x
▲ VercelNitro⚠️missing-----

🔍 Observability: Express | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNitro7/12
🐘 PostgresNitro5/12
▲ VercelExpress8/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express💻 Local10/12
Next.js (Turbopack)🌐 Redis7/12
Nitro💻 Local12/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Starter: Community world (local development)
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

- Propagate Retry-After header as WorkflowAPIError.retryAfter on 429 responses
- Add withThrottleRetry wrapper for both workflow and step handlers
- Re-enqueue workflows on 5xx errors with exponential backoff (5s, 30s, 120min)
- Track serverErrorRetryCount in queue payload for retry budgeting
- Expose delaySeconds on QueueOptions interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
);
// Short wait: sleep in-process, then retry once
await new Promise((resolve) =>
setTimeout(resolve, retryAfterSeconds * 1000)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we account for function execution time limits specifically in the case of Vercel world? If the serverless fuction is already close to the end of it's limit and the workflow server throws a 429, adding a 10 sec sleep could potentially exceed the function execution limit and the function could get SIGKILLd midway.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

The workflow layer should never take much more than a few seconds, so I think it's highly unlikely that we'd run into timeouts, so I'm not too worried about this, but technically a concern

@karthikscale3

Copy link
Copy Markdown
Contributor

Rest of the code looks good except the one concern I have around function execution limits in the case of vercel world.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this looks solid — the 429 throttle retry and 5xx backoff are clean additions. The withThrottleRetry wrapper with the "safe to retry because 429 is pre-processing" invariant is a nice design. A few things to consider:

world-local doesn't support delaySeconds — The QueueOptions interface was updated and world-vercel passes it through, but world-local/src/queue.ts silently ignores opts?.delaySeconds. If a 5xx retry fires in local dev, the message will be re-enqueued but delivered immediately instead of after the intended backoff. Not a blocker since 5xx from world-local is uncommon, but could cause confusing behavior during local fault testing (which the PR description mentions wanting to add).

Step handler diff is mostly re-indentation — confirmed the actual logic changes are just wrapping in withThrottleRetry. The step handler does NOT get 5xx retry (only 429), which makes sense since the queue's built-in redelivery + step_started idempotency handles that.

@pranaygp

Copy link
Copy Markdown
Contributor

Inline review notes

Since GitHub's review API is having issues, posting inline comments here:


packages/core/src/runtime.ts:291delaySecondSteps = [5, 30, 7200]

The third backoff step is 7200s (2 hours). Is the expectation that persistent 5xx would trigger an alert/incident on the server side well before this fires? A brief comment explaining the rationale for these specific values would help future readers.


packages/core/src/runtime.ts:290-307serverErrorRetryCount lifecycle

The serverErrorRetryCount resets on each new workflow invocation (since the next queueMessage after a successful step won't include it). This means each invocation gets a fresh retry budget. That seems right for transient 5xx, but if the server is persistently down, each invocation will independently burn through all 3 retries before failing. Just want to confirm this is the intended behavior vs. tracking the count on the run itself.


packages/core/src/runtime/helpers.ts:425withThrottleRetry short-wait path

If the first in-process retry succeeds, great. But if it gets a different error (not 429), that error is thrown — which means the handler fails entirely rather than deferring to queue. This seems correct since a non-429 error should propagate, just want to confirm the intent.


packages/world-vercel/src/utils.ts:298Retry-After parsing

nit: Retry-After can also be an HTTP-date (RFC 9110). This only handles the delay-seconds format. Fine if you control the server, but worth a comment noting the assumption.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this is clean and well-structured. The two-tier 429 strategy (in-process for short waits, defer to queue for long waits) is nice. A few questions and concerns below.

*
* Safe to retry the entire handler because 429 is sent from server middleware
* before the request is processed — no server state has changed.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comment says "Safe to retry the entire handler because 429 is sent from server middleware before the request is processed — no server state has changed."

This holds if the 429 always hits on the first API call in the handler. But the handler makes multiple world API calls sequentially (e.g. runs.getevents.create(run_started) → replay → events.create(run_completed)). If a later call gets 429'd, the retry re-executes everything from the top.

For the workflow handler this is probably fine since replay is deterministic and events are idempotent.

For the step handler this is more concerning — the retry would re-execute user step code, which may not be idempotent. Is the assumption that the workflow-server's rate limiting middleware rejects at the connection level (so all calls in one handler invocation either succeed or all fail)? If so, worth documenting that assumption.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch, fixed

Comment threadpackages/core/src/runtime.ts Outdated
err.status >= 500
) {
const retryCount = serverErrorRetryCount ?? 0;
const delaySecondSteps = [5, 30, 7200]; // 5s, 30s, 120min

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The third step is 7200 seconds = 2 hours. Is that intentional? That's a very long time for a workflow to be stalled on a server error. If the 500 was truly transient, 2 hours feels excessive. If it's a sustained outage, you'd probably want to fail rather than retry after 2h when the server might have moved on.

Maybe something like [5, 30, 120] (5s, 30s, 2min) would be more practical?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Changed to [5, 30, 120]. Realistically if we keep getting 500s, it'd be nice to re-try the run much later so we have a change to incident-mitigate, but I think it's fine to fail runs instead too

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

cool. this was fully AI generated review btw. idk where it came up with 5s, 30, 120 - but I do agree i we should have some shorter ones before getting to 7200 anyway

}

const maxRetries = stepFn.maxRetries ?? DEFAULT_STEP_MAX_RETRIES;
return await withThrottleRetry(async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The step handler gets withThrottleRetry for 429s but no equivalent 5xx retry logic like the workflow handler has. If a world API call (e.g. events.create for step_started or step_completed) returns a 5xx, it'll be caught by the step's error handling and burn a step retry attempt — re-executing potentially expensive user code for what was a transient infrastructure error.

Is this intentional for this PR's scope, or should step handlers also get 5xx backoff retry?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

500s are handled via regular step re-try. 429s are only special-cased because they allow the step-retry mechanism to use the retry-after header instead of guessing at the retry

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

right but the comment is about not burning a full step attempt for transient 5xx. doesn't need to be addressed in this PR necessarily but good to think about. happy to move in any direction for now since they're all better than nothing and then come back to this in the future with real experience

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Makes sense that steps rely on their existing retry mechanism. One concern though: with withThrottleRetry removed, a 429 from the world API during step execution (e.g. on step_started or step_completed) will now burn a step retry attempt — potentially exhausting maxRetries without the user's code ever being at fault.

Ideally 429s should be retried transparently without consuming an attempt. A targeted retry around just the world API calls (not wrapping the user code execution) could thread that needle. Worth tracking as a follow-up.

idempotencyKey?: string;
headers?: Record<string, string>;
/** Delay message delivery by this many seconds */
delaySeconds?: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good that this was promoted from the vercel-specific type to the shared interface. Note that world-local currently ignores delaySeconds entirely — so 5xx retries in local dev will fire immediately instead of with backoff. Not a blocker but worth a follow-up or at minimum a comment.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Seems fine to ignore for local world, since there are no 429s

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

although we probably do actually want to support delaySeconds in local world anyway as we start using this option in queue more often in the future for more use cases. even if it's not implemented in this PR, I think we should leave an explicit comment that local world ignores this since it's a nuance. We should later have an e2e test that checks for this behaviour and would fail on local world without proper queue delaySeconds implementation (cc @TooTallNate )

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/src/runtime/step-handler.ts Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good! The removal of withThrottleRetry from the step handler was the right fix for the re-execution safety concern.

One thing worth tracking as a follow-up: with withThrottleRetry removed from steps, a 429 from the world API during step execution (e.g. on step_started or step_completed) will now burn a step retry attempt. This means a transient throttle from the server could exhaust a step's maxRetries without the user's code ever being at fault. Ideally 429s should be retried transparently without consuming an attempt — maybe a targeted retry around just the world API calls (not the user code execution) would thread that needle.

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: [core] Handle 429 and 500 errors from worlds in runtime

Overall this is a solid approach to handling transient server errors. The 429 throttle retry with short/long path split is well-designed, and the 500 retry via re-enqueue with exponential backoff is a safe pattern. A few items to address below.

);
runtimeLogger.warn(
'Throttled again on retry, deferring to queue',
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redundant check: this is already inside if (WorkflowAPIError.is(err)), so the second WorkflowAPIError.is(err) on this line is always true. Should just be if (err.status === 429).

step = startResult.step;
} catch (err) {
if (WorkflowAPIError.is(err)) {
if (WorkflowAPIError.is(err) && err.status === 429) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The step handler has 429 handling here inside the step_started catch block, but unlike the workflow handler, it does not use withThrottleRetry and does not have the short-wait in-process retry path. This means a brief 429 (e.g., retryAfter=2s) will always defer to the queue rather than sleeping in-process. Is that intentional? If steps should also get the in-process retry for short waits, consider wrapping with withThrottleRetry similarly to the workflow handler.

if (retryAfterSeconds < 10) {
runtimeLogger.warn(
'Throttled by workflow-server (429), retrying in-process',
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: if retryAfter is undefined (header missing), this defaults to 1 second. For 429 responses without a Retry-After header, 1 second might be too aggressive. A slightly higher default (e.g., 3-5s) would be more conservative and reduce the chance of hitting the server again immediately while it's under load.

workflowStartedAt = +workflowRun.startedAt;

// At this point, the workflow is "running" and `startedAt` should
// definitely be set.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The entire workflow handler body is now wrapped in withThrottleRetry. This means if a 429 is thrown by any world call during workflow execution (e.g., world.runs.get, world.events.create, runWorkflow), the entire handler will be retried from scratch.

  1. If run_started event was already created successfully and then a later call throws 429, the retry will call world.runs.get again and find the run already running, which is handled correctly.
  2. However, if run_completed event creation throws 429, the retry would re-run the entire workflow (replay + execution). The workflow replay should be deterministic, but this could be expensive. Worth documenting this tradeoff in a comment.

});
// Use the run entity from the event response (no extra get call needed)
if (!result.run) {
if (workflowRun.status === 'pending') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The 500 retry re-enqueues with a new requestedAt and incremented serverErrorRetryCount. The traceCarrier is serialized fresh via serializeTraceCarrier(). If the trace context matters for correlating retries, this creates a new trace for each retry attempt. Consider whether preserving the original traceContext from the message would be preferable for observability (linking all retry attempts to the same parent trace).

WorldParseFormat,
PeerService,
RpcSystem,
RpcService,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note: the HTTP Retry-After header can also be an HTTP-date (e.g., Wed, 21 Oct 2015 07:28:00 GMT). The parseInt approach correctly returns NaN for date values, falling back to undefined. This is fine since the workflow-server likely only sends numeric values, but a comment noting this would be helpful.

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Comment threadpackages/core/src/runtime/helpers.ts Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
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.

3 participants

@VaguelySerious@karthikscale3@pranaygp