feat(sdk): add discriminated errors, type guards, toJSON, withRetry#171
Merged
Conversation
The April 2026 SDK DX consultation called out `instanceof Run402Error`
as fragile across SDK copies and realms — duplicate npm installs,
bundler chunk splits, ESM/CJS interop, and V8-isolate boundaries all
silently break identity-based checks. Coding agents run in environments
they don't control, so a failed `instanceof` shows up as "my error
handler doesn't catch the right errors" with no diagnostic.
This change adds value-based discriminators alongside the existing
class hierarchy. All additions are non-breaking — `instanceof` keeps
working for single-copy single-realm callers.
New surface (exported from @run402/sdk and @run402/sdk/node):
- Run402ErrorKind: union of "payment_required" | "project_not_found"
| "unauthorized" | "api_error" | "network_error" | "local_error"
| "deploy_error".
- Run402Error.kind: stable string discriminator on every subclass.
- Run402Error.isRun402Error: structural brand for cross-copy detection.
- Run402Error.toJSON(): canonical envelope (name/kind/message/status/
code/category/retryable/safeToRetry/mutationState/traceId/context/
details/nextActions/body). Run402DeployError extends with phase/
resource/operationId/planId/fix/logs/rolledBack. JSON.stringify on
any subclass now produces a populated object instead of "{}".
- Type guards: isRun402Error, isPaymentRequired, isProjectNotFound,
isUnauthorized, isApiError, isNetworkError, isLocalError,
isDeployError, isRetryableRun402Error.
- withRetry(fn, opts?): exponential-backoff helper using
isRetryableRun402Error as the default policy. Defaults: 3 attempts,
250ms base, 5s cap. Throws the LAST error after exhausting attempts
so the caller's catch handler sees the original structured envelope.
Pair with idempotencyKey baked into the closure for safe mutation
retries.
Fixed two anonymous Run402Error subclasses in
sdk/src/namespaces/projects.ts (which TypeScript correctly rejected
once `kind` became abstract) — replaced with LocalError, which is
the right class for local-config-missing errors.
Docs: rewrote the "Errors" sections in sdk/README.md and
sdk/llms-sdk.txt to lead with the type-guard pattern, document the
Run402ErrorKind union, and show the canonical `withRetry` recipe paired
with idempotencyKey. The CI gate (sdk-docs-fidelity) verifies every
TS-fenced example compiles against the new types.
Tests: 45 new unit tests in sdk/src/errors.test.ts (kind discriminators,
brand, each guard's narrowing, isRetryableRun402Error policy across
status codes / flags / kinds, toJSON envelope shape including subclass
fields, instanceof back-compat) and sdk/src/retry.test.ts (happy path,
retry on retryable failures, non-retryable short-circuit, last-error
preservation, custom retryIf, onRetry callback ordering and buggy-logger
isolation, exponential backoff timing capped by maxDelayMs, closure
idempotency-key passthrough).
Side fix (npm test glob): the root package.json's test script had
unquoted globs (`sdk/src/**/*.test.ts`), which the shell expanded
without globstar — silently dropping every depth-1 test in
core/src, sdk/src, and src. Local `npm test` reported 266; CI's
inline quoted-glob command saw 566. Quoted the globs in
package.json so local matches CI. Net effect: 300 previously-hidden
tests now run on every local `npm test`.
Spec at run402-private/openspec/changes/sdk-error-discrimination/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
b5006bc to
2c49417
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The April 2026 SDK DX consultation called out
instanceof Run402Erroras fragile across SDK copies and realms. Identity-based checks fail silently when the consumer's runtime holds a different copy of the SDK — duplicate npm installs, bundler chunk splits, ESM/CJS interop, and V8-isolate boundaries are all common in agent-controlled environments.This change adds value-based discriminators alongside the existing class hierarchy. All additions are non-breaking —
instanceofcontinues to work in the simple single-copy case.What's added
Run402ErrorKindRun402Error.kindRun402Error.isRun402Errortrue as const); survives realm/copy boundariesRun402Error.toJSON()JSON.stringify(error)returns populated object instead of"{}"isRun402Error,isPaymentRequired,isProjectNotFound,isUnauthorized,isApiError,isNetworkError,isLocalError,isDeployError,isRetryableRun402ErrorwithRetry(fn, opts?)isRetryableRun402Erroras the default policy. Defaults: 3 attempts, 250ms base, 5s cap. Throws LAST error after exhausting attempts.Recommended pattern
Side fix:
npm testglob quotingFound while wiring the new tests:
package.json's test script had unquoted globs (sdk/src/**/*.test.ts). The shell expanded these without globstar, silently dropping every depth-1 test incore/src,sdk/src, andsrc. Localnpm testreported 266; CI's inline quoted-glob command saw 566. Quoted the globs so local matches CI — 300 previously-hidden tests now run.CI is unaffected (the workflow uses its own inline quoted command); this just makes local match.
Test plan
node --test --import tsx 'sdk/src/**/*.test.ts').isRetryableRun402Errorpolicy across status codes / flags / kinds,toJSONenvelope including subclass fields,instanceofback-compat,withRetryhappy-path / retryable / non-retryable / last-error / customretryIf/onRetryordering and buggy-logger isolation / exponential-backoff timing / closure idempotency-key passthrough.npm test(root) — 566 tests pass, plustest:docsgreen (21 TS snippets across 3 docs compile).npm run check:docs --workspace=@run402/sdk— green (rewritten error/retry examples insdk/README.mdandsdk/llms-sdk.txtcompile).Reviewer focus
instanceofcontinues to work. No prototype-chain changes. Existingif (e instanceof PaymentRequired) { ... }call sites in MCP / CLI / OpenClaw are not touched.Run402Errorsubclasses fixed. Twoclass extends Run402Error {}instances insdk/src/namespaces/projects.tswere rejected by TypeScript oncekindbecame abstract — replaced withLocalError(the correct class for local-config-missing).withRetrydefaults are sane (3 attempts × 250ms base × 5s cap matchesp-retry/axios-retry).instanceof.Spec lives at
run402-private/openspec/changes/sdk-error-discrimination/.🤖 Generated with Claude Code