refactor: replace HTTP status code checks with semantic error types#1342
refactor: replace HTTP status code checks with semantic error types#1342
Conversation
🧪 E2E Test Results❌ Some tests failed Summary
❌ Failed Tests🌍 Community Worlds (56 failed)mongodb (3 failed):
redis (2 failed):
turso (51 failed):
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
❌ 🌍 Community Worlds
✅ 📋 Other
|
This stack of pull requests is managed by Graphite. Learn more about stacking. |
8480225 to
6655edc
Compare
1563db7 to
2841381
Compare
🦋 Changeset detectedLatest commit: 8ba3db5 The changes in this PR will be included in the next version bump. This PR includes changesets to release 20 packages
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 |
🦋 Changeset detectedLatest commit: 2841381 The changes in this PR will be included in the next version bump. This PR includes changesets to release 20 packages
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 |
c463509 to
256bb01
Compare
- Add RUN_ERROR_CODES (USER_ERROR, RUNTIME_ERROR) to @workflow/errors - Populate errorCode in run_failed events via classifyRunError() - Update web UI StatusBadge to show amber dot for infrastructure errors - Improve world-local queue error logging (concise, no body dump) - Improve schema validation error messages (concise, verbose behind DEBUG) - Add e2e tests for error code flow and infrastructure error retry Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
256bb01 to
1f26e4e
Compare
…runtime Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
0a9c6f7 to
cb18ce3
Compare
There was a problem hiding this comment.
Pull request overview
Refactors error handling across the core runtime and world implementations to replace runtime-side HTTP status code branching (409/410/429) with semantic error types emitted by worlds, reducing coupling to HTTP transport details.
Changes:
- Add new semantic error classes in
@workflow/errors:EntityConflictError,RunExpiredError,ThrottleError. - Update
world-vercelto map HTTP 409/410/429 to those semantic errors at the request boundary. - Update
world-local,world-postgres, and@workflow/coreruntime call sites to throw/catch semantic errors instead ofWorkflowAPIError.status.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/world-vercel/src/utils.ts | Map HTTP 409/410/429 responses to semantic errors in makeRequest() |
| packages/world-postgres/src/storage.ts | Replace storage-layer WorkflowAPIError({status:409/410}) with semantic errors |
| packages/world-local/src/storage/events-storage.ts | Replace local storage WorkflowAPIError({status:409/410}) with semantic errors |
| packages/world-local/src/fs.ts | Use EntityConflictError for write conflicts instead of HTTP-encoded errors |
| packages/errors/src/index.ts | Introduce semantic error classes for conflict/expired/throttle cases |
| packages/core/src/runtime/suspension-handler.ts | Replace runtime checks for 409/410 with semantic errors in suspension flow |
| packages/core/src/runtime/step-handler.ts | Replace runtime checks for 409/410/429 with semantic errors in step processing |
| packages/core/src/runtime/step-handler.test.ts | Update tests to throw EntityConflictError instead of WorkflowAPIError(409) |
| packages/core/src/runtime/runs.ts | Use EntityConflictError for wake-up conflict handling |
| packages/core/src/runtime/runs.test.ts | Update tests to use EntityConflictError |
| packages/core/src/runtime.ts | Replace runtime status checks (409/410) with semantic error guards |
| .changeset/semantic-world-errors.md | Publish patch bumps for affected packages |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
packages/errors/src/index.ts
Outdated
| * Thrown when attempting to operate on a run that has been cleaned up or expired. | ||
| * Replaces WorkflowAPIError with status 410. |
There was a problem hiding this comment.
Fixed in b45a71d — broadened the docstring to cover terminal state, expired, and completed/failed runs.
| // 425 Too Early: retryAfter timestamp not reached yet | ||
| // Return timeout to queue so it retries later | ||
| if (WorkflowAPIError.is(err) && err.status === 425) { | ||
| // Parse retryAfter from error response meta |
There was a problem hiding this comment.
Fixed in b45a71d — introduced TooEarlyError semantic type for the 425 case. All three world implementations (vercel, local, postgres) now throw TooEarlyError with a retryAfter: Date field, and the runtime checks TooEarlyError.is(err) instead of inspecting HTTP status.
| } else if (WorkflowAPIError.is(err) && err.status === 404) { | ||
| // Hook may have already been disposed or never created | ||
| runtimeLogger.info('Hook not found for disposal, continuing', { | ||
| workflowRunId: runId, | ||
| correlationId: queueItem.correlationId, | ||
| message: err.message, | ||
| }); |
There was a problem hiding this comment.
Fixed in b45a71d — replaced WorkflowAPIError.status === 404 with HookNotFoundError.is(err), which is already thrown by all world implementations for missing hooks.
packages/errors/src/index.ts
Outdated
| } | ||
|
|
||
| /** | ||
| * Thrown when attempting to modify an entity that is already in a terminal state. |
There was a problem hiding this comment.
Fixed in b45a71d — broadened the docstring to cover all 409-style conflicts (terminal state, already exists, creation conflicts).
- Add RUN_ERROR_CODES (USER_ERROR, RUNTIME_ERROR) to @workflow/errors - Populate errorCode in run_failed events via classifyRunError() - Update web UI StatusBadge to show amber dot for infrastructure errors - Improve world-local queue error logging (concise, no body dump) - Improve schema validation error messages (concise, verbose behind DEBUG) - Add e2e tests for error code flow and infrastructure error retry Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Merge latest changes from pgp/run-failed-schema-vailidation-error - Broaden EntityConflictError/RunExpiredError docstrings to match actual usage - Add TooEarlyError semantic type to replace status === 425 checks - Replace status === 404 with HookNotFoundError.is() in suspension-handler Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add RUN_ERROR_CODES (USER_ERROR, RUNTIME_ERROR) to @workflow/errors - Populate errorCode in run_failed events via classifyRunError() - Update web UI StatusBadge to show amber dot for infrastructure errors - Improve world-local queue error logging (concise, no body dump) - Improve schema validation error messages (concise, verbose behind DEBUG) - Add e2e tests for error code flow and infrastructure error retry Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…on-error Convert new 409/410 status checks from base branch to semantic error types (EntityConflictError, RunExpiredError) in runtime.ts and suspension-handler.ts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Merge latest main (base PR was merged)
- Convert all remaining WorkflowAPIError({ status: 409 }) to EntityConflictError
in world-local and world-postgres
- Convert run 404s to WorkflowRunNotFoundError, hook 404s to HookNotFoundError
- Strip fake HTTP status codes from remaining WorkflowAPIError calls
(step/wait/event not-found, validation errors)
- Update world-local and world-postgres tests to assert semantic error types
- Only remaining status check in runtime is err.status >= 500 for
genuine HTTP infrastructure errors from world-vercel
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
📊 Benchmark Results
workflow with no steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) workflow with 1 step💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) workflow with 10 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express workflow with 25 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express | Nitro workflow with 50 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) Promise.all with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro | Express Promise.all with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) Promise.all with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) Promise.race with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) | Nitro Promise.race with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) Promise.race with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) Stream Benchmarks (includes TTFB metrics)workflow with stream💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) SummaryFastest Framework by WorldWinner determined by most benchmark wins
Fastest World by FrameworkWinner determined by most benchmark wins
Column Definitions
Worlds:
|
| @@ -1,4 +1,4 @@ | |||
| import { WorkflowAPIError } from '@workflow/errors'; | |||
| import { EntityConflictError, WorkflowAPIError } from '@workflow/errors'; | |||
There was a problem hiding this comment.
which error paths in the runtime are still checking/handling WorkflowApiError - and what's happening when this is encountered? is it properly bubbling up unknown APIErrors to the queue so that queue can retry?
There was a problem hiding this comment.
Two places in the runtime still check/handle WorkflowAPIError:
-
step-handler.ts:428— checkserr.status >= 500on errors that surface through user code. This catches infrastructure errors (genuine HTTP 5xx from world-vercel) and re-throws them so they bubble up to the queue handler for retry instead of consuming a step attempt. Non-5xxWorkflowAPIErrors fall through and are treated as user code errors (recorded as step failure). -
suspension-handler.ts:184— checksWorkflowAPIError.is(err) && err.status === 404as a fallback for hook disposal. This is needed becauseworld-vercel'smakeRequest()doesn't map 404 toHookNotFoundError(onlygetHookByToken()inhooks.tsdoes that). See the thread on TooTallNate's comment for context.
Both paths properly bubble unknown errors to the queue for retry — any error that isn't explicitly caught gets re-thrown.
TooTallNate
left a comment
There was a problem hiding this comment.
Review Summary
Solid refactor. The motivation is sound — the runtime was coupling to HTTP status codes that only make sense for world-vercel, forcing world-local and world-postgres to fake HTTP responses. The new semantic error types (EntityConflictError, RunExpiredError, ThrottleError, TooEarlyError) are well-named, consistently applied, and make the catch sites significantly more readable.
What looks good
- Comprehensive sweep: Every
WorkflowAPIError.is(err) && err.status === Npattern in the runtime has been converted. The one remainingerr.status >= 500check is correctly justified — it only applies to genuine HTTP infrastructure errors fromworld-vercel. - TooEarlyError is a nice cleanup: The old
(err as any).meta?.retryAfterhack is replaced with a typedretryAfter: Dateproperty on the error class. Much cleaner. - World implementations are consistent:
world-localandworld-postgresmirror each other's conversions. - world-vercel mapping is correctly placed: The HTTP-to-semantic conversion happens at the
makeRequest()boundary, which is exactly the right location. - Tests are updated: All test assertions now use semantic error types.
- Changeset is correct: All 5 affected packages are listed.
Nits (non-blocking)
See inline comments.
| } | ||
|
|
||
| /** | ||
| * Thrown when an operation cannot proceed because a required timestamp |
There was a problem hiding this comment.
Nit: meta is accepted in the constructor options but never stored on the instance. This is dead code — either remove it or assign it to a property if it is intended for future use.
There was a problem hiding this comment.
Fixed in e08a8fa — removed the dead meta option from the constructor.
packages/world-vercel/src/utils.ts
Outdated
| } | ||
| if (response.status === 410) { | ||
| const error = new RunExpiredError(defaultMessage); | ||
| span?.setAttributes({ |
There was a problem hiding this comment.
Nit: The span attributes + recordException pattern is duplicated across the 409/410/425/429 branches. Consider extracting a small helper like:
function throwSemanticError(error: Error, span: Span | undefined, code: string): never {
span?.setAttributes({ ...ErrorType(code) });
span?.recordException?.(error);
throw error;
}This would reduce the ~20 lines of duplication down to single calls and make it easier to add new status mappings in the future.
There was a problem hiding this comment.
Fixed in e08a8fa — extracted a throwWithTrace helper that handles span attributes + recordException, replacing the duplicated pattern across all status branches.
packages/world-local/src/queue.ts
Outdated
|
|
||
| console.error( | ||
| `[world-local] Queue message failed (attempt ${attempt + 1}/${maxAttempts}, status ${response.status}): ${text}`, | ||
| `[world-local] Queue message failed (attempt ${attempt + 1}/${attempt + 1 + defaultRetriesLeft}, status ${response.status}): ${text}`, |
There was a problem hiding this comment.
Nit: This expression attempt + 1 + defaultRetriesLeft computes a different total on each iteration because defaultRetriesLeft is decremented each loop. On the first failure (attempt=0, defaultRetriesLeft=2) it shows 1/3, second (attempt=1, defaultRetriesLeft=1) shows 2/3, but third (attempt=2, defaultRetriesLeft=0) shows 3/3. So it happens to produce correct output by coincidence. But it's fragile — consider capturing const maxAttempts = defaultRetriesLeft before the loop (the line this PR removed) or just hardcoding 3.
There was a problem hiding this comment.
Fixed in e08a8fa — restored const maxAttempts = 3 before the loop and use it in the log message.
TooTallNate
left a comment
There was a problem hiding this comment.
Updated Review: Behavioral Regression Found
After a deeper audit focused specifically on whether the same errors that previously bubbled up to the queue retry handler still do so (and vice versa), I found one confirmed behavioral regression and one observation.
BLOCKING: HTTP 404 on hook disposal no longer swallowed (world-vercel path)
See inline comment on suspension-handler.ts. In the world-vercel code path, when the workflow server returns HTTP 404 for a hook_disposed event, makeRequest() throws a generic WorkflowAPIError({ status: 404 }) — it does NOT throw HookNotFoundError. The old code caught this via WorkflowAPIError.is(err) && err.status === 404. The new code checks HookNotFoundError.is(err), which won't match.
This means a 404 on hook disposal that was previously gracefully logged-and-continued will now throw, causing the suspension handler to fail and likely trigger queue retries.
Observation: world-local/world-postgres error handling is actually IMPROVED
The old runtime code checked WorkflowAPIError.is(err) && err.status === 409. Since world-local and world-postgres now throw EntityConflictError (which is NOT a WorkflowAPIError and has no status property), the old catch predicates would have never matched errors from these backends. The new EntityConflictError.is(err) predicate correctly catches them. This is a correctness improvement, not a regression — but worth calling out as a non-trivial behavioral change for local/postgres backends.
All other catch sites: verified correct
runtime.ts: All 4 catch sites are purely semantic renames. Non-409/410 errors bubble up identically.step-handler.ts: All catch sites are correct. The remainingWorkflowAPIError.is(err) && err.status >= 500check for infrastructure errors in user code scope is unaffected and still works.runs.ts: The single catch site is correct.
| ); | ||
| } else if (HookNotFoundError.is(err)) { | ||
| // Hook may have already been disposed or never created | ||
| runtimeLogger.info('Hook not found for disposal, continuing', { |
There was a problem hiding this comment.
Blocking: Behavioral regression for world-vercel on 404 during hook disposal.
In the old code, this catch block had:
if (WorkflowAPIError.is(err)) {
if (err.status === 409 || err.status === 410) { /* log, continue */ }
else if (err.status === 404) { /* log, continue */ }
else { throw err; }
}The new code replaces the err.status === 404 check with HookNotFoundError.is(err). However, in the world-vercel path, makeRequest() does not map HTTP 404 to HookNotFoundError — it throws a generic WorkflowAPIError({ status: 404 }). The only place 404 is converted to HookNotFoundError is in getHookByToken() (hooks.ts:115), not in the event creation path.
So when the workflow server returns 404 for a hook_disposed event (hook already disposed or never created):
- Old behavior: Caught by
err.status === 404, logged, and swallowed gracefully. - New behavior:
HookNotFoundError.is(err)returns false (it's aWorkflowAPIError, not aHookNotFoundError), falls through tothrow err, bubbles up to queue retry handler.
Suggested fixes (pick one):
-
Add error translation in
world-vercel/src/events.tsincreateWorkflowRunEvent(): catchWorkflowAPIError({ status: 404 })and re-throw asHookNotFoundErrorwhen the event type ishook_disposedorhook_received. This mirrors the existing pattern ingetHookByToken()athooks.ts:115. -
Add a fallback in this catch block:
HookNotFoundError.is(err) || (WorkflowAPIError.is(err) && err.status === 404)— less clean but preserves behavior immediately.
There was a problem hiding this comment.
Good catch — fixed in e08a8fa using option 2 (fallback). The catch block now checks:
HookNotFoundError.is(err) ||
(WorkflowAPIError.is(err) && err.status === 404)with a comment explaining that world-vercel returns WorkflowAPIError({ status: 404 }) for missing hooks during event creation, while world-local and world-postgres throw HookNotFoundError directly.
Option 1 (translating in world-vercel/events.ts) would be cleaner long-term but would require knowing the event type context inside makeRequest() to distinguish hook 404s from other 404s — so the fallback is the safer fix for now.
There was a problem hiding this comment.
Updated in c15c613 — went with option 1 instead. The 404 → HookNotFoundError translation now lives in world-vercel/src/events.ts inside createWorkflowRunEvent. For hook-related event types (hook_created, hook_disposed, hook_received, hook_conflict), a 404 from the server is translated to HookNotFoundError.
The runtime's suspension-handler is back to just checking HookNotFoundError.is(err) with no WorkflowAPIError fallback.
- Remove dead `meta` option from TooEarlyError constructor (TooTallNate) - Extract `throwWithTrace` helper to deduplicate span recording in world-vercel makeRequest (TooTallNate) - Restore `maxAttempts` const for stable retry count logging (TooTallNate) - Fix behavioral regression: add WorkflowAPIError 404 fallback in suspension-handler hook disposal to handle world-vercel path where makeRequest doesn't map 404 to HookNotFoundError (TooTallNate) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move the 404 → HookNotFoundError translation into world-vercel's createWorkflowRunEvent, where we know the event type context. For hook-related events (hook_created, hook_disposed, hook_received, hook_conflict), a 404 from the server means the hook was not found. This removes the WorkflowAPIError 404 fallback from the runtime's suspension-handler, keeping the runtime fully decoupled from HTTP status codes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Refactors the runtime and world implementations to stop relying on HTTP status codes for control flow, replacing them with semantic error types from @workflow/errors (e.g., conflict/expired/throttle/too-early). This keeps HTTP concerns contained to the HTTP boundary (world-vercel) and simplifies runtime error handling.
Changes:
- Add semantic error classes (
EntityConflictError,RunExpiredError,ThrottleError,TooEarlyError) to@workflow/errors. - Update core runtime and world implementations (local/postgres/vercel) to throw/catch semantic errors instead of checking
WorkflowAPIError.status. - Update affected unit/integration tests and add a changeset for patch releases.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/world-vercel/src/utils.ts | Map specific HTTP status codes to semantic errors at the request boundary. |
| packages/world-vercel/src/events.ts | Translate hook-related 404s into HookNotFoundError for event creation. |
| packages/world-postgres/test/storage.test.ts | Update assertions to reflect semantic/not-status-based errors. |
| packages/world-postgres/src/storage.ts | Replace fake HTTP-status WorkflowAPIErrors with semantic errors (and status-free WorkflowAPIError where appropriate). |
| packages/world-local/src/storage/events-storage.ts | Replace status-based WorkflowAPIErrors with semantic errors for conflicts/expired/too-early/hook-not-found. |
| packages/world-local/src/storage.test.ts | Update concurrency/conflict tests to assert semantic errors. |
| packages/world-local/src/queue.ts | Minor retry loop refactor (no behavior change intended). |
| packages/world-local/src/fs.ts | Replace “file already exists” 409-style errors with EntityConflictError. |
| packages/errors/src/index.ts | Introduce new semantic error types with type guards. |
| packages/core/src/runtime/suspension-handler.ts | Replace status checks with semantic error type checks. |
| packages/core/src/runtime/step-handler.ts | Replace status checks with semantic error type checks; use TooEarlyError.retryAfter. |
| packages/core/src/runtime/step-handler.test.ts | Update mocks/assertions for conflict handling to use EntityConflictError. |
| packages/core/src/runtime/runs.ts | Replace 409 status check with EntityConflictError. |
| packages/core/src/runtime/runs.test.ts | Update wake-up tests to use EntityConflictError. |
| packages/core/src/runtime.ts | Replace 409/410 status checks with EntityConflictError / RunExpiredError. |
| .changeset/semantic-world-errors.md | Publish patch changes for affected packages. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| if (response.status === 425) { | ||
| const retryAfterDate = retryAfter | ||
| ? new Date(Date.now() + retryAfter * 1000) | ||
| : undefined; | ||
| throwWithTrace( |
There was a problem hiding this comment.
Good catch — fixed in f529deb. The Retry-After header is now parsed unconditionally (not gated on response.status === 429), so both 425 and 429 responses get the server-provided delay.
packages/world-vercel/src/events.ts
Outdated
| 'hook_created', | ||
| 'hook_disposed', | ||
| 'hook_received', | ||
| 'hook_conflict', |
There was a problem hiding this comment.
Fixed in f529deb — narrowed to hook_disposed and hook_received only, matching world-local's hookEventsRequiringExistence set. hook_created and hook_conflict don't require the hook to already exist, so a 404 there would be a different kind of error.
VaguelySerious
left a comment
There was a problem hiding this comment.
WorkflowAPIError remains for: Genuinely unexpected HTTP errors from world-vercel (the only real HTTP boundary)
Should we name this WorkflowWorldError? Might be easier for users to interpret
- Parse Retry-After header unconditionally so TooEarlyError gets the server-provided delay instead of always falling back to ~1s - Narrow hookEventsRequiringExistence to only hook_disposed and hook_received (matching world-local's set), since hook_created and hook_conflict don't imply the hook must already exist Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Re: @VaguelySerious's suggestion to rename human: actually maybe we should do this because we're already breaking the functionality of WorkflowAPIError - if people are relying on WorkflowAPIError checks right now in their own code (unlikely), then their catch handlers will start to fail when we're bubbling up semantic errors instead. Maybe this is a good time to make the breaking error change (patch release is fine for beta though I think) |
Breaking change: rename WorkflowAPIError → WorkflowWorldError to better reflect that this error represents world (storage backend) failures, not HTTP API errors specifically. Updated across all packages: errors, core, world-local, world-vercel, world-postgres, workflow, and web. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Done in 8ba3db5 — renamed |
|
I also started a new PR to export and document the new error types. Including documenting the pattern of starting a workflow in response to |

Summary
Replaces all patterns of catching
WorkflowAPIErrorwith HTTP status codes in the runtime and eliminates fake HTTP status code construction in world-local and world-postgres. The runtime now uses semantic error types exclusively, and world implementations throw semantic errors instead of simulating HTTP responses.Breaking change:
WorkflowAPIErrorhas been renamed toWorkflowWorldErrorto better reflect that this error represents world (storage backend) failures, not HTTP API errors specifically.Problem
The runtime had ~18+ places doing this:
And world-local/world-postgres were faking HTTP status codes:
This is wrong because:
world-vercelimplementation detail leaking into the runtimeworld-localandworld-postgreshad to fake HTTP status codes to matchSolution
Semantic error types (
@workflow/errors)EntityConflictErrorstatus === 409RunExpiredErrorstatus === 410ThrottleErrorstatus === 429retryAfterTooEarlyErrorstatus === 425Existing types also leveraged:
HookNotFoundErrorreplacesstatus === 404for hooksWorkflowRunNotFoundErrorreplacesstatus === 404for runsWorkflowAPIError→WorkflowWorldErrorRenamed to better reflect its role as the catch-all for world (storage backend) failures. It remains for:
err.status >= 500check for infrastructure errors surfacing through user codeWorld implementations updated
makeRequest()maps HTTP 409/410/425/429 → semantic errors at the throw site.createWorkflowRunEvent()translates 404 →HookNotFoundErrorfor hook events.Runtime updated
All catch sites now use semantic checks:
Test plan
helpers.test.ts,step-handler.test.ts,runs.test.ts,classify-error.test.ts,storage.test.ts(world-local),storage.test.ts(world-postgres)🤖 Generated with Claude Code