Skip to content

feat(sdk): add discriminated errors, type guards, toJSON, withRetry#171

Merged
MajorTal merged 1 commit into
mainfrom
claude/sdk-error-discrimination
May 1, 2026
Merged

feat(sdk): add discriminated errors, type guards, toJSON, withRetry#171
MajorTal merged 1 commit into
mainfrom
claude/sdk-error-discrimination

Conversation

@MajorTal
Copy link
Copy Markdown
Collaborator

@MajorTal MajorTal commented May 1, 2026

Summary

The April 2026 SDK DX consultation called out instanceof Run402Error as 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 — instanceof continues to work in the simple single-copy case.

What's added

New
Run402ErrorKind Discriminated union of seven literal strings (one per subclass)
Run402Error.kind Abstract field; concrete subclasses provide a literal
Run402Error.isRun402Error Structural brand (true as const); survives realm/copy boundaries
Run402Error.toJSON() Canonical envelope; JSON.stringify(error) returns 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 LAST error after exhausting attempts.

Recommended pattern

import {
  run402,
  withRetry,
  isPaymentRequired,
  isDeployError,
} from "@run402/sdk/node";

const r = run402();

try {
  const release = await withRetry(
    () => r.deploy.apply(spec, { idempotencyKey: "deploy-2026-05-01" }),
    { attempts: 3, onRetry: (_e, n, ms) => console.warn(`retry ${n} in ${ms}ms`) },
  );
} catch (e) {
  if (isPaymentRequired(e)) { /* ... */ }
  else if (isDeployError(e)) console.error(JSON.stringify(e));
  else throw e;
}

Side fix: npm test glob quoting

Found 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 in core/src, sdk/src, and src. Local npm test reported 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

  • 217 SDK unit tests pass (node --test --import tsx 'sdk/src/**/*.test.ts').
  • 45 new tests cover kind literals, brand, every guard's narrowing, isRetryableRun402Error policy across status codes / flags / kinds, toJSON envelope including subclass fields, instanceof back-compat, withRetry happy-path / retryable / non-retryable / last-error / custom retryIf / onRetry ordering and buggy-logger isolation / exponential-backoff timing / closure idempotency-key passthrough.
  • npm test (root) — 566 tests pass, plus test:docs green (21 TS snippets across 3 docs compile).
  • npm run check:docs --workspace=@run402/sdk — green (rewritten error/retry examples in sdk/README.md and sdk/llms-sdk.txt compile).

Reviewer focus

  1. instanceof continues to work. No prototype-chain changes. Existing if (e instanceof PaymentRequired) { ... } call sites in MCP / CLI / OpenClaw are not touched.
  2. Anonymous Run402Error subclasses fixed. Two class extends Run402Error {} instances in sdk/src/namespaces/projects.ts were rejected by TypeScript once kind became abstract — replaced with LocalError (the correct class for local-config-missing).
  3. withRetry defaults are sane (3 attempts × 250ms base × 5s cap matches p-retry/axios-retry).
  4. Docs lead with the new pattern but don't deprecate instanceof.

Spec lives at run402-private/openspec/changes/sdk-error-discrimination/.

🤖 Generated with Claude Code

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>
@MajorTal MajorTal force-pushed the claude/sdk-error-discrimination branch from b5006bc to 2c49417 Compare May 1, 2026 11:45
@MajorTal MajorTal merged commit 9c16252 into main May 1, 2026
4 checks passed
@MajorTal MajorTal deleted the claude/sdk-error-discrimination branch May 1, 2026 12:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant