Skip to content

Commit b5006bc

Browse files
MajorTalclaude
andcommitted
feat(sdk): add discriminated errors, type guards, toJSON, withRetry
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>
1 parent 5caa09c commit b5006bc

10 files changed

Lines changed: 1016 additions & 46 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"test:skill": "node --test --import tsx SKILL.test.ts",
3030
"test:sync": "node --test --import tsx sync.test.ts",
3131
"test:docs": "npm run check:docs --workspace=@run402/sdk -- --skip-build",
32-
"test": "node --experimental-test-module-mocks --test --import tsx SKILL.test.ts sync.test.ts core/src/**/*.test.ts sdk/src/**/*.test.ts src/**/*.test.ts && node --test cli-e2e.test.mjs cli-help.test.mjs && npm run test:docs",
32+
"test": "node --experimental-test-module-mocks --test --import tsx SKILL.test.ts sync.test.ts 'core/src/**/*.test.ts' 'sdk/src/**/*.test.ts' 'src/**/*.test.ts' && node --test cli-e2e.test.mjs cli-help.test.mjs && npm run test:docs",
3333
"test:e2e": "node --test cli-e2e.test.mjs cli-help.test.mjs",
3434
"test:help": "node --test cli-help.test.mjs",
3535
"test:integration": "node --test --import tsx core/src/siwx-integration.integ.ts",

sdk/README.md

Lines changed: 77 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -143,42 +143,101 @@ const resumed = await r.deploy.resume(operationId);
143143

144144
### Errors
145145

146-
All failures throw subclasses of `Run402Error`:
146+
All failures throw subclasses of `Run402Error`. Every subclass carries a stable
147+
`kind` discriminator string and an `isRun402Error` brand:
148+
149+
| Class | `kind` | When | Notable fields |
150+
|---|---|---|---|
151+
| `PaymentRequired` | `"payment_required"` | HTTP 402 | x402 payment requirements in `body` |
152+
| `ProjectNotFound` | `"project_not_found"` | Project ID not in the credential provider | `projectId` |
153+
| `Unauthorized` | `"unauthorized"` | HTTP 401 / 403 ||
154+
| `ApiError` | `"api_error"` | Other non-2xx responses | `status`, `body` |
155+
| `NetworkError` | `"network_error"` | Fetch rejected with no HTTP response | `cause` |
156+
| `LocalError` | `"local_error"` | Local-host issues (filesystem, signing) | `cause` |
157+
| `Run402DeployError` | `"deploy_error"` | Structured envelope from the deploy state machine (v1.34+) | `code`, `phase`, `operationId`, `safeToRetry`, `mutationState`, `nextActions` |
158+
159+
**Branch with type guards, not `instanceof`.** `instanceof X` is an identity
160+
check on the class object — it fails silently when the consumer's runtime
161+
holds a different copy of the SDK (duplicate npm installs, bundler chunk
162+
splits, ESM/CJS interop, V8-isolate realms). The exported guards
163+
(`isPaymentRequired`, `isDeployError`, …) check `isRun402Error` + `kind`,
164+
which is identity-free and survives all of those scenarios. `instanceof`
165+
continues to work for back-compat in the simple single-copy case.
147166

148-
| Class | When | Notable fields |
149-
|---|---|---|
150-
| `PaymentRequired` | HTTP 402 | x402 payment requirements |
151-
| `ProjectNotFound` | Project ID not in the credential provider ||
152-
| `Unauthorized` | HTTP 401 / 403 ||
153-
| `ApiError` | Other non-2xx responses | `status`, `body` |
154-
| `NetworkError` | Fetch rejected with no HTTP response ||
155-
| `LocalError` | Local-host issues (filesystem, signing) ||
156-
| `Run402DeployError` | Structured envelope from the deploy state machine (v1.34+) | `code`, `phase`, `operationId`, `safeToRetry`, `mutationState`, `nextActions` |
167+
```ts
168+
import {
169+
run402,
170+
isPaymentRequired,
171+
isDeployError,
172+
type ReleaseSpec,
173+
} from "@run402/sdk/node";
157174

158-
Branch on the structured fields, not English `message` text:
175+
declare const spec: ReleaseSpec;
176+
const r = run402();
177+
178+
try {
179+
await r.deploy.apply(spec);
180+
} catch (e) {
181+
if (isPaymentRequired(e)) {
182+
// e is narrowed to PaymentRequired
183+
// present payment requirements to the user — read e.body, e.context, etc.
184+
} else if (isDeployError(e) && e.safeToRetry) {
185+
// e is narrowed to Run402DeployError; it's safe to retry with the same idempotency key
186+
} else throw e;
187+
}
188+
```
189+
190+
`Run402Error.toJSON()` returns a canonical envelope, so `JSON.stringify(e)`
191+
produces a populated structured object instead of the empty `"{}"` plain
192+
`Error` produces. Use this for telemetry, MCP tool results, CLI JSON output,
193+
and any inter-process boundary where the error needs to survive serialization.
194+
195+
#### Retry idempotent operations with `withRetry`
196+
197+
`withRetry(fn, opts?)` wraps any async call with exponential backoff. It uses
198+
`isRetryableRun402Error` (the canonical "should I retry this?" policy: 408 /
199+
425 / 429 / 5xx / `NetworkError` / gateway-flagged `retryable` or
200+
`safeToRetry`) by default. Pair it with the SDK method's own
201+
`idempotencyKey` so retried mutations dedup server-side:
159202

160203
```ts
161204
import {
162205
run402,
163-
PaymentRequired,
164-
Run402DeployError,
206+
withRetry,
207+
isPaymentRequired,
208+
isDeployError,
165209
type ReleaseSpec,
166210
} from "@run402/sdk/node";
167211

168212
declare const spec: ReleaseSpec;
169213
const r = run402();
170214

171215
try {
172-
await r.deploy.apply(spec);
216+
const release = await withRetry(
217+
() => r.deploy.apply(spec, { idempotencyKey: "deploy-2026-05-01" }),
218+
{
219+
attempts: 3,
220+
onRetry: (e, attempt, delayMs) =>
221+
process.stderr.write(`retry ${attempt} in ${delayMs}ms\n`),
222+
},
223+
);
224+
console.log(release.urls);
173225
} catch (e) {
174-
if (e instanceof PaymentRequired) {
175-
// present payment requirements to the user
176-
} else if (e instanceof Run402DeployError && e.safeToRetry) {
177-
// safe to retry — same idempotency key
226+
if (isPaymentRequired(e)) {
227+
// ... present payment
228+
} else if (isDeployError(e)) {
229+
// log structured envelope for triage
230+
process.stderr.write(JSON.stringify(e) + "\n");
178231
} else throw e;
179232
}
180233
```
181234

235+
Defaults: 3 attempts (1 initial + 2 retries), 250 ms base delay, 5 s cap. Pass
236+
a custom `retryIf` to override the default policy (e.g., retry on
237+
`PaymentRequired` if your sandbox auto-funds). After exhausting attempts
238+
`withRetry` throws the LAST error — your catch handler sees the original
239+
structured envelope, not a wrapper.
240+
182241
The SDK never calls `process.exit`. Each interface (MCP tools, CLI, your code) wraps with its own error behavior.
183242

184243
## Stability

sdk/llms-sdk.txt

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -104,42 +104,78 @@ const info = await r.projects.info(projectId);
104104

105105
## Errors
106106

107-
All failures throw subclasses of `Run402Error`. Branch on instance, not English message:
107+
All failures throw subclasses of `Run402Error`. Every subclass carries a stable
108+
`kind` string discriminator and an `isRun402Error` brand. Branch with the
109+
exported type guards (or by comparing `e.kind`) — NOT with `instanceof X`:
110+
identity-based checks fail silently when the consumer's runtime holds a
111+
different copy of the SDK (duplicate npm installs, bundler chunk splits,
112+
ESM/CJS interop, V8-isolate realms). `instanceof` continues to work for
113+
single-copy single-realm callers as a back-compat path.
114+
115+
| Class | `kind` | When | Notable fields |
116+
|---|---|---|---|
117+
| `PaymentRequired` | `"payment_required"` | HTTP 402 | x402 payment requirements in `body` |
118+
| `ProjectNotFound` | `"project_not_found"` | Project ID not in the credential provider | `projectId` |
119+
| `Unauthorized` | `"unauthorized"` | HTTP 401 / 403 | — |
120+
| `ApiError` | `"api_error"` | Other non-2xx responses | `status`, `body` |
121+
| `NetworkError` | `"network_error"` | Fetch rejected with no HTTP response | `cause` |
122+
| `LocalError` | `"local_error"` | Local-host issues (filesystem, signing) | `cause` |
123+
| `Run402DeployError` | `"deploy_error"` | Structured envelope from the deploy state machine (v1.34+) | `code`, `phase`, `operationId`, `safeToRetry`, `mutationState`, `nextActions` |
108124

109-
| Class | When | Notable fields |
110-
|---|---|---|
111-
| `PaymentRequired` | HTTP 402 | x402 payment requirements in `body` |
112-
| `ProjectNotFound` | Project ID not in the credential provider | `projectId` |
113-
| `Unauthorized` | HTTP 401 / 403 | — |
114-
| `ApiError` | Other non-2xx responses | `status`, `body` |
115-
| `NetworkError` | Fetch rejected with no HTTP response | `cause` |
116-
| `LocalError` | Local-host issues (filesystem, signing) | — |
117-
| `Run402DeployError` | Structured envelope from the deploy state machine (v1.34+) | `code`, `phase`, `operationId`, `safeToRetry`, `mutationState`, `nextActions` |
125+
The exported `Run402ErrorKind` union type (`"payment_required" | "project_not_found" | "unauthorized" | "api_error" | "network_error" | "local_error" | "deploy_error"`) supports exhaustive `switch` statements with TypeScript exhaustiveness checking.
118126

119127
```ts
120128
import {
121129
run402,
122-
PaymentRequired,
123-
Run402DeployError,
130+
withRetry,
131+
isPaymentRequired,
132+
isDeployError,
124133
type ReleaseSpec,
125134
} from "@run402/sdk/node";
126135

127136
declare const spec: ReleaseSpec;
128137
const r = run402();
129138

130139
try {
131-
await r.deploy.apply(spec);
140+
const release = await withRetry(
141+
() => r.deploy.apply(spec, { idempotencyKey: "deploy-2026-05-01" }),
142+
{
143+
attempts: 3,
144+
onRetry: (_e, attempt, delayMs) =>
145+
process.stderr.write(`retry ${attempt} in ${delayMs}ms\n`),
146+
},
147+
);
148+
console.log(release.urls);
132149
} catch (e) {
133-
if (e instanceof PaymentRequired) {
134-
// present payment requirements to the user
135-
} else if (e instanceof Run402DeployError && e.safeToRetry) {
136-
// safe to retry — same idempotency key
150+
if (isPaymentRequired(e)) {
151+
// narrowed to PaymentRequired — read e.body for the x402 quote
152+
} else if (isDeployError(e)) {
153+
// narrowed to Run402DeployError — log the structured envelope for triage
154+
process.stderr.write(JSON.stringify(e) + "\n");
137155
} else throw e;
138156
}
139157
```
140158

141159
`Run402DeployError.code` is one of `MIGRATION_FAILED`, `MIGRATION_CHECKSUM_MISMATCH`, `BASE_RELEASE_CONFLICT`, `PAYMENT_REQUIRED`, `SCHEMA_SETTLE_TIMEOUT`, `ACTIVATION_FAILED`, `STORAGE_UNAVAILABLE`, `SITE_STAGE_FAILED`, `FUNCTION_BUILD_FAILED`, `CONTENT_UPLOAD_FAILED`, `INVALID_SPEC`, `OPERATION_NOT_FOUND`, `MIGRATE_GATE_ACTIVE`, `INTERNAL_ERROR`, `NETWORK_ERROR`, `PROJECT_NOT_FOUND` (or any other string the gateway emits — consumers SHALL treat unknown codes as opaque). Pair it with the structured `nextActions` advisory array carried in the error body.
142160

161+
### Type guards and the canonical retry policy
162+
163+
The SDK exports identity-free guards plus a single canonical "should I retry this?" function:
164+
165+
- `isRun402Error(e)` — true for any `Run402Error` subclass instance, regardless of which SDK copy created it.
166+
- `isPaymentRequired(e)`, `isProjectNotFound(e)`, `isUnauthorized(e)`, `isApiError(e)`, `isNetworkError(e)`, `isLocalError(e)`, `isDeployError(e)` — narrow `unknown` to the named subclass.
167+
- `isRetryableRun402Error(e)` — encapsulates the retry policy: `e.retryable || e.safeToRetry || kind === "network_error" || status in {408, 425, 429} || status >= 500`. Returns `false` for non-Run402 inputs so it's safe to call from any `catch` block.
168+
169+
`Run402Error.toJSON()` returns a canonical envelope (`name`, `kind`, `message`, `status`, `code`, `category`, `retryable`, `safeToRetry`, `mutationState`, `traceId`, `context`, `details`, `nextActions`, `body`). `Run402DeployError.toJSON()` extends it with `phase`, `resource`, `operationId`, `planId`, `fix`, `logs`, `rolledBack`. `JSON.stringify(error)` produces a populated structured object — never the empty `"{}"` plain `Error` produces.
170+
171+
### `withRetry(fn, opts?)`
172+
173+
`withRetry` runs an async function with exponential backoff. Defaults: 3 attempts (1 + 2 retries), 250 ms base delay, 5 s cap. Uses `isRetryableRun402Error` as the default retry decision. Pair with the SDK method's own `idempotencyKey` so retried mutations dedup server-side — the closure carries the same key on every attempt.
174+
175+
`RetryOptions`: `attempts?: number`, `baseDelayMs?: number`, `maxDelayMs?: number`, `retryIf?: (error, attempt) => boolean`, `onRetry?: (error, attempt, delayMs) => void`.
176+
177+
After exhausting attempts, `withRetry` throws the LAST observed error — your catch handler sees the original structured envelope, not a wrapper. A buggy `onRetry` that throws is swallowed; the retry chain is unaffected.
178+
143179
## The patterns
144180

145181
### Paste-and-go assets — content-addressed URLs with SRI

0 commit comments

Comments
 (0)