ts-procedures 6.2.0 → 7.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +2 -1
- package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +38 -1
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +253 -3
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +60 -2
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +1 -1
- package/agent_config/copilot/copilot-instructions.md +3 -0
- package/agent_config/cursor/cursorrules +3 -0
- package/build/client/augment-error-map.test-d.d.ts +10 -0
- package/build/client/augment-error-map.test-d.js +14 -0
- package/build/client/augment-error-map.test-d.js.map +1 -0
- package/build/client/bind-callable.test.d.ts +1 -0
- package/build/client/bind-callable.test.js +132 -0
- package/build/client/bind-callable.test.js.map +1 -0
- package/build/client/call.d.ts +14 -2
- package/build/client/call.js +96 -9
- package/build/client/call.js.map +1 -1
- package/build/client/call.test.js +50 -1
- package/build/client/call.test.js.map +1 -1
- package/build/client/classify-error.d.ts +11 -0
- package/build/client/classify-error.js +49 -0
- package/build/client/classify-error.js.map +1 -0
- package/build/client/classify-error.test.d.ts +1 -0
- package/build/client/classify-error.test.js +55 -0
- package/build/client/classify-error.test.js.map +1 -0
- package/build/client/error-dispatch.d.ts +1 -1
- package/build/client/error-dispatch.js +1 -1
- package/build/client/errors.d.ts +55 -4
- package/build/client/errors.js +54 -7
- package/build/client/errors.js.map +1 -1
- package/build/client/errors.test.js +89 -4
- package/build/client/errors.test.js.map +1 -1
- package/build/client/fetch-adapter.d.ts +2 -1
- package/build/client/fetch-adapter.js +2 -1
- package/build/client/fetch-adapter.js.map +1 -1
- package/build/client/fetch-adapter.test.js +12 -0
- package/build/client/fetch-adapter.test.js.map +1 -1
- package/build/client/index.d.ts +5 -3
- package/build/client/index.js +29 -3
- package/build/client/index.js.map +1 -1
- package/build/client/resolve-options.d.ts +32 -1
- package/build/client/resolve-options.js +32 -16
- package/build/client/resolve-options.js.map +1 -1
- package/build/client/resolve-options.test.js +67 -6
- package/build/client/resolve-options.test.js.map +1 -1
- package/build/client/result-type.test-d.d.ts +1 -0
- package/build/client/result-type.test-d.js +28 -0
- package/build/client/result-type.test-d.js.map +1 -0
- package/build/client/safe-call.test.d.ts +1 -0
- package/build/client/safe-call.test.js +137 -0
- package/build/client/safe-call.test.js.map +1 -0
- package/build/client/stream.d.ts +1 -1
- package/build/client/stream.js +22 -8
- package/build/client/stream.js.map +1 -1
- package/build/client/stream.test.js +11 -1
- package/build/client/stream.test.js.map +1 -1
- package/build/client/types.d.ts +117 -3
- package/build/codegen/bundle-size.test.d.ts +1 -0
- package/build/codegen/bundle-size.test.js +70 -0
- package/build/codegen/bundle-size.test.js.map +1 -0
- package/build/codegen/e2e.test.js +108 -7
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-client-runtime.js +8 -0
- package/build/codegen/emit-client-runtime.js.map +1 -1
- package/build/codegen/emit-client-runtime.test.js +6 -2
- package/build/codegen/emit-client-runtime.test.js.map +1 -1
- package/build/codegen/emit-client-types.d.ts +7 -2
- package/build/codegen/emit-client-types.js +29 -8
- package/build/codegen/emit-client-types.js.map +1 -1
- package/build/codegen/emit-client-types.test.js +20 -8
- package/build/codegen/emit-client-types.test.js.map +1 -1
- package/build/codegen/emit-errors.d.ts +1 -1
- package/build/codegen/emit-errors.js +1 -1
- package/build/codegen/emit-index.js +1 -1
- package/build/codegen/emit-index.js.map +1 -1
- package/build/codegen/emit-scope.js +37 -25
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-scope.test.js +310 -14
- package/build/codegen/emit-scope.test.js.map +1 -1
- package/docs/client-and-codegen.md +77 -7
- package/docs/client-error-handling.md +357 -0
- package/docs/superpowers/plans/2026-04-29-safe-result-api.md +2293 -0
- package/docs/superpowers/specs/2026-04-29-safe-result-api-design.md +324 -0
- package/package.json +1 -1
- package/src/client/augment-error-map.test-d.ts +22 -0
- package/src/client/bind-callable.test.ts +137 -0
- package/src/client/call.test.ts +65 -1
- package/src/client/call.ts +111 -9
- package/src/client/classify-error.test.ts +65 -0
- package/src/client/classify-error.ts +59 -0
- package/src/client/error-dispatch.ts +1 -1
- package/src/client/errors.test.ts +108 -4
- package/src/client/errors.ts +70 -7
- package/src/client/fetch-adapter.test.ts +15 -0
- package/src/client/fetch-adapter.ts +5 -2
- package/src/client/index.ts +60 -3
- package/src/client/resolve-options.test.ts +83 -5
- package/src/client/resolve-options.ts +61 -16
- package/src/client/result-type.test-d.ts +51 -0
- package/src/client/safe-call.test.ts +157 -0
- package/src/client/stream.test.ts +13 -1
- package/src/client/stream.ts +25 -8
- package/src/client/types.ts +137 -3
- package/src/codegen/bundle-size.test.ts +76 -0
- package/src/codegen/e2e.test.ts +113 -7
- package/src/codegen/emit-client-runtime.test.ts +7 -2
- package/src/codegen/emit-client-runtime.ts +8 -0
- package/src/codegen/emit-client-types.test.ts +22 -7
- package/src/codegen/emit-client-types.ts +35 -10
- package/src/codegen/emit-errors.ts +1 -1
- package/src/codegen/emit-index.ts +1 -1
- package/src/codegen/emit-scope.test.ts +337 -14
- package/src/codegen/emit-scope.ts +39 -35
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
# Client Error Handling
|
|
2
|
+
|
|
3
|
+
Canonical reference for downstream developers using ts-procedures generated clients to handle errors.
|
|
4
|
+
|
|
5
|
+
## 1. Two Forms — Pick Either, Mix as Needed
|
|
6
|
+
|
|
7
|
+
Every generated RPC and API callable supports two equivalent call styles.
|
|
8
|
+
|
|
9
|
+
**Throwing form** (the canonical form): the call returns the response directly or throws a typed error class.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
const response = await client.users.GetUser({ userId })
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**`.safe()` form**: the call always resolves (never rejects) and returns a discriminated `Result<T, E>`. Useful when you want exhaustive failure handling with TypeScript narrowing inside an exhaustive `switch`.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
const r = await client.users.GetUser.safe({ userId })
|
|
19
|
+
if (r.ok) {
|
|
20
|
+
// r.value is typed as the response
|
|
21
|
+
} else {
|
|
22
|
+
// r.kind narrows the failure category
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Both forms fire the same hooks, respect the same defaults, and go through the same error registry. You can mix them freely — per call site, use whichever reads most naturally.
|
|
27
|
+
|
|
28
|
+
> Note: `.safe()` is only available on RPC and API callables. Stream callables keep the throwing form — their failure surface (mid-stream errors, backpressure, early disconnection) does not fit cleanly into `Result<T, E>`.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 2. Framework Error Class Taxonomy
|
|
33
|
+
|
|
34
|
+
All framework error classes are exported from `ts-procedures/client` (or from `./_types` in self-contained generated clients).
|
|
35
|
+
|
|
36
|
+
| Class | When it is thrown | Notable properties |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `ClientHttpError` | Adapter returned a non-2xx response and no error registry entry matched | `status`, `headers`, `body`, `procedureName`, `scope`, `cause` |
|
|
39
|
+
| `ClientNetworkError` | Adapter threw a `TypeError` (e.g., DNS failure, connection refused) | `procedureName`, `scope`, `cause` |
|
|
40
|
+
| `ClientTimeoutError` | The request was aborted because the timeout signal fired | `procedureName`, `scope`, `timeoutMs`, `cause` |
|
|
41
|
+
| `ClientAbortError` | The request was aborted because a user-supplied signal fired | `procedureName`, `scope`, `reason`, `cause` |
|
|
42
|
+
| `ClientPathParamError` | A required path parameter was missing when building the request | `cause` |
|
|
43
|
+
| `ClientParseError` | Reserved for adapter authors who want stricter response-body parsing | `procedureName`, `scope`, `cause` |
|
|
44
|
+
| `ClientStreamError` | A mid-stream SSE error event was received | `procedureName`, `scope`, `cause` |
|
|
45
|
+
|
|
46
|
+
Every class carries `cause` to surface the underlying platform error — typically a `TypeError` or `DOMException` — when one exists.
|
|
47
|
+
|
|
48
|
+
`ClientRequestError` is a deprecated alias for `ClientHttpError`. It is retained for one minor release after 7.0.0 and will be removed in a subsequent minor. See the [migration guide](#8-migration-6x--7x) below.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 3. Throwing Form: Narrowing Pattern
|
|
53
|
+
|
|
54
|
+
The throwing form is the most common pattern. Catch the error and narrow by class:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { ClientHttpError, ClientTimeoutError, ClientNetworkError, ClientAbortError } from 'ts-procedures/client'
|
|
58
|
+
// ApiErrors is exported from your generated index, e.g.:
|
|
59
|
+
// import { ApiErrors } from './generated'
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const response = await client.records.DownloadRecord(
|
|
63
|
+
{ body: { recordId } },
|
|
64
|
+
{ timeout: 60_000 },
|
|
65
|
+
)
|
|
66
|
+
if (response.pdfBase64) downloadBase64AsPdf(response.pdfBase64)
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err instanceof ApiErrors.UseCaseError) {
|
|
69
|
+
// Typed error declared on the route — strongly typed body
|
|
70
|
+
Alerts.error(err.body.message)
|
|
71
|
+
} else if (err instanceof ClientHttpError) {
|
|
72
|
+
Alerts.error(`Server returned ${err.status}`)
|
|
73
|
+
} else if (err instanceof ClientTimeoutError) {
|
|
74
|
+
Alerts.error('Request timed out')
|
|
75
|
+
} else if (err instanceof ClientNetworkError) {
|
|
76
|
+
Alerts.error('Network error — check your connection')
|
|
77
|
+
} else if (err instanceof ClientAbortError) {
|
|
78
|
+
// User cancelled — silent
|
|
79
|
+
} else {
|
|
80
|
+
throw err // Programmer or framework bug — fail loud
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`ApiErrors.UseCaseError` is illustrative. The actual namespace is `${ServiceName}Errors` based on your `--service-name` codegen option (default: `ApiErrors`). Each error class name matches the key you declared in your server-side error taxonomy.
|
|
86
|
+
|
|
87
|
+
The `else { throw err }` rethrow at the end is intentional: any error that slips past all your `instanceof` checks is a bug — do not swallow it.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 4. `.safe()` Form: Exhaustive Narrowing
|
|
92
|
+
|
|
93
|
+
The same operation rewritten with `.safe()`. The call never rejects. `r.ok` is the top-level gate; `r.kind` narrows the failure:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { ApiErrors } from './generated'
|
|
97
|
+
|
|
98
|
+
const r = await client.records.DownloadRecord.safe(
|
|
99
|
+
{ body: { recordId } },
|
|
100
|
+
{ timeout: 60_000 },
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if (r.ok) {
|
|
104
|
+
if (r.value.pdfBase64) downloadBase64AsPdf(r.value.pdfBase64)
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
switch (r.kind) {
|
|
109
|
+
case 'typed':
|
|
110
|
+
if (r.error instanceof ApiErrors.UseCaseError) Alerts.error(r.error.body.message)
|
|
111
|
+
break
|
|
112
|
+
case 'http':
|
|
113
|
+
Alerts.error(`Server returned ${r.error.status}`)
|
|
114
|
+
break
|
|
115
|
+
case 'network':
|
|
116
|
+
Alerts.error('Network error')
|
|
117
|
+
break
|
|
118
|
+
case 'timeout':
|
|
119
|
+
Alerts.error('Request timed out')
|
|
120
|
+
break
|
|
121
|
+
case 'aborted':
|
|
122
|
+
break // User cancelled — silent
|
|
123
|
+
case 'parse':
|
|
124
|
+
case 'usage':
|
|
125
|
+
throw r.error // Programmer or framework bug — fail loud
|
|
126
|
+
case 'unknown':
|
|
127
|
+
Alerts.error('Unknown error')
|
|
128
|
+
console.error(r.error)
|
|
129
|
+
break
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The `Result<T, E>` type is:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
type Result<T, ETyped> =
|
|
137
|
+
| { ok: true; value: T }
|
|
138
|
+
| { ok: false; kind: 'typed'; error: ETyped } // route-declared, registry-dispatched
|
|
139
|
+
| { ok: false; kind: 'http'; error: ClientHttpError }
|
|
140
|
+
| { ok: false; kind: 'network'; error: ClientNetworkError }
|
|
141
|
+
| { ok: false; kind: 'timeout'; error: ClientTimeoutError }
|
|
142
|
+
| { ok: false; kind: 'aborted'; error: ClientAbortError }
|
|
143
|
+
| { ok: false; kind: 'parse'; error: ClientParseError }
|
|
144
|
+
| { ok: false; kind: 'usage'; error: ClientPathParamError }
|
|
145
|
+
| { ok: false; kind: 'unknown'; error: unknown }
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
When a route declares no typed errors, the generated callable returns `ResultNoTyped<T>` — the `kind: 'typed'` arm is omitted entirely so IDE hovers stay clean.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## 5. The Three Failure-Source Paths
|
|
153
|
+
|
|
154
|
+
Understanding where errors originate helps you know what to expect at each stage:
|
|
155
|
+
|
|
156
|
+
| # | Stage | Source of failure | Resulting kind(s) |
|
|
157
|
+
|---|---|---|---|
|
|
158
|
+
| 1 | **Pre-adapter** | `buildAdapterRequest` throws synchronously (e.g. missing path param) | `'usage'` (`ClientPathParamError`) — bypasses the classifier and `onError` hook |
|
|
159
|
+
| 2 | **Adapter throws** | `adapter.request()` rejects (network failure, abort, or custom) | `'network'` / `'timeout'` / `'aborted'` / custom kinds / `'unknown'` |
|
|
160
|
+
| 3 | **Adapter returns non-2xx** | `response.status < 200` or `>= 300` | `'typed'` (registry match) or `'http'` (no match → `ClientHttpError`) |
|
|
161
|
+
|
|
162
|
+
Stage 1 (`'usage'`) is always a programming error — a required path parameter is absent. These bypass `onError` and the error classifier because the request was never sent.
|
|
163
|
+
|
|
164
|
+
Stage 2 errors go through `adapter.classifyError` (if present) then `defaultClassifyError`. Anything the classifier does not recognise lands as `'unknown'`.
|
|
165
|
+
|
|
166
|
+
Stage 3: when a non-2xx response body has a `name` field that matches an entry in the error registry, the client constructs and throws the matching typed error class. When no entry matches, the client throws `ClientHttpError`.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## 6. Custom Error Categories — `ClientErrorMap` Augmentation
|
|
171
|
+
|
|
172
|
+
The `kind` discriminant set is open for extension via TypeScript declaration merging. This lets you teach the type system about custom error kinds produced by your adapter:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
// Declare the new kind once in your app — usually alongside your adapter setup.
|
|
176
|
+
class RateLimitError extends Error {
|
|
177
|
+
constructor(public retryAfter: number) {
|
|
178
|
+
super('rate limited')
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// With the standard (non-self-contained) client:
|
|
183
|
+
declare module 'ts-procedures/client' {
|
|
184
|
+
interface ClientErrorMap {
|
|
185
|
+
rateLimited: RateLimitError
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
For self-contained generated clients, the augmentation target shifts to the bundled types file:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
// With a self-contained (code-generated) client:
|
|
194
|
+
declare module './generated/_types' {
|
|
195
|
+
interface ClientErrorMap {
|
|
196
|
+
rateLimited: RateLimitError
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
This is the same pattern as `RequestMeta` augmentation — if you have used that, this is identical.
|
|
202
|
+
|
|
203
|
+
After augmentation, `Result`'s union expands automatically. The new `kind` is exhaustively narrowable in every `.safe()` switch:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
const r = await client.records.DownloadRecord.safe({ body: { recordId } })
|
|
207
|
+
if (!r.ok && r.kind === 'rateLimited') {
|
|
208
|
+
Alerts.error(`Retry in ${r.error.retryAfter}s`)
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**`'typed'` is reserved.** Do not add it to your `ClientErrorMap` augmentation — it is used internally for route-declared errors dispatched via the codegen `errors` key. Adding it produces a TypeScript error about overlapping discriminants.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## 7. Adapter-Side Classifier Composition
|
|
217
|
+
|
|
218
|
+
Custom error kinds only flow through the `Result` union if the adapter's classifier produces them. Configure the classifier on `createFetchAdapter`:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
import { createFetchAdapter, defaultClassifyError } from 'ts-procedures/client'
|
|
222
|
+
|
|
223
|
+
const adapter = createFetchAdapter({
|
|
224
|
+
classifyError: (raw, ctx) => {
|
|
225
|
+
if (raw instanceof MyRateLimitError) {
|
|
226
|
+
return { kind: 'rateLimited', error: raw }
|
|
227
|
+
}
|
|
228
|
+
// Fall through to the built-in classifier for TypeError / DOMException / etc.
|
|
229
|
+
return defaultClassifyError(raw, ctx)
|
|
230
|
+
},
|
|
231
|
+
})
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`defaultClassifyError` handles:
|
|
235
|
+
- `TypeError` → `ClientNetworkError` (`kind: 'network'`)
|
|
236
|
+
- `DOMException { name: 'AbortError' }` + timeout signal aborted → `ClientTimeoutError` (`kind: 'timeout'`)
|
|
237
|
+
- `DOMException { name: 'AbortError' }` + user signal aborted → `ClientAbortError` (`kind: 'aborted'`)
|
|
238
|
+
|
|
239
|
+
Composing with `defaultClassifyError` at the end is the recommended pattern. Adapter authors who want to completely replace the default can omit the fallthrough — anything the classifier returns `null` for lands as `kind: 'unknown'`.
|
|
240
|
+
|
|
241
|
+
The classifier is called only when the adapter throws (Stage 2 above). Non-2xx responses go through the error registry path (Stage 3), not the classifier.
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
## 8. Migration: 6.x → 7.x
|
|
246
|
+
|
|
247
|
+
Three breaking changes in 7.0.0 affect error handling.
|
|
248
|
+
|
|
249
|
+
### 8a. `ClientRequestError` Renamed to `ClientHttpError`
|
|
250
|
+
|
|
251
|
+
`ClientRequestError` is a deprecated alias for `ClientHttpError`, retained for one minor release after 7.0.0.
|
|
252
|
+
|
|
253
|
+
**Action**: rename your imports from `ClientRequestError` to `ClientHttpError`. The `instanceof ClientRequestError` guard continues to work until the alias is removed.
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
// Before
|
|
257
|
+
import { ClientRequestError } from 'ts-procedures/client'
|
|
258
|
+
if (err instanceof ClientRequestError) { ... }
|
|
259
|
+
|
|
260
|
+
// After
|
|
261
|
+
import { ClientHttpError } from 'ts-procedures/client'
|
|
262
|
+
if (err instanceof ClientHttpError) { ... }
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### 8b. Raw `DOMException` / `TypeError` No Longer Reach Consumer Catch Blocks
|
|
266
|
+
|
|
267
|
+
Before 7.0.0, the fetch adapter let platform errors propagate directly. After 7.0.0, all thrown errors from the adapter are normalized through the classifier before reaching your code.
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
// Before 6.x — catching raw platform errors
|
|
271
|
+
try {
|
|
272
|
+
await client.users.GetUser(params)
|
|
273
|
+
} catch (e) {
|
|
274
|
+
if (e instanceof DOMException && e.name === 'AbortError') {
|
|
275
|
+
// handle abort
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// After 7.x — catching framework error classes
|
|
280
|
+
try {
|
|
281
|
+
await client.users.GetUser(params)
|
|
282
|
+
} catch (e) {
|
|
283
|
+
if (e instanceof ClientTimeoutError) {
|
|
284
|
+
// timed out
|
|
285
|
+
} else if (e instanceof ClientAbortError) {
|
|
286
|
+
// user-cancelled
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
The original platform error is preserved on `error.cause` if you need to inspect it.
|
|
292
|
+
|
|
293
|
+
### 8c. `onError` Hook Receives the Normalized Framework Error
|
|
294
|
+
|
|
295
|
+
Before 7.0.0, the `onError` hook saw the raw `DOMException` or `TypeError`. After 7.0.0, the hook sees the normalized framework class (`ClientNetworkError`, `ClientTimeoutError`, etc.), with the original platform error on `.cause`.
|
|
296
|
+
|
|
297
|
+
**Action**: update any hook logic that inspected the raw error shape. Most consumers only call `console.error(error)` or log `error.message` and are unaffected.
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## 9. FAQ — `.safe()` and Retry Loops
|
|
302
|
+
|
|
303
|
+
**Q: My retry loop using `.safe()` triggers `onError` once per attempt. Is that intentional?**
|
|
304
|
+
|
|
305
|
+
Yes. `onError` fires on every failure regardless of which call style you use. Telemetry consumers want to see all attempts uniformly.
|
|
306
|
+
|
|
307
|
+
To suppress logging on a retry loop, you have two options:
|
|
308
|
+
|
|
309
|
+
**Option 1 — per-call `onError` no-op:**
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
313
|
+
const r = await client.users.GetUser.safe(params, {
|
|
314
|
+
onError: () => {}, // suppress onError for this call
|
|
315
|
+
})
|
|
316
|
+
if (r.ok) return r.value
|
|
317
|
+
// inspect r.kind to decide whether to retry
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
**Option 2 — flag in `meta`, check in the global `onError`:**
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
// Augment RequestMeta once in your app:
|
|
325
|
+
declare module 'ts-procedures/client' {
|
|
326
|
+
interface RequestMeta { isRetry?: boolean }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// In createClient setup:
|
|
330
|
+
const client = createApiClient({
|
|
331
|
+
basePath: 'https://api.example.com',
|
|
332
|
+
hooks: {
|
|
333
|
+
onError: ({ request, error }) => {
|
|
334
|
+
if (request.meta?.isRetry) return // skip telemetry for retries
|
|
335
|
+
logger.error(error)
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
// At call sites:
|
|
341
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
342
|
+
const r = await client.users.GetUser.safe(params, {
|
|
343
|
+
meta: { isRetry: attempt > 0 },
|
|
344
|
+
})
|
|
345
|
+
if (r.ok) return r.value
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
A framework-level "skip `onError` for `.safe()`" toggle was deliberately deferred to a future minor release. The workarounds above are sufficient for the common case.
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
## 10. Reference
|
|
354
|
+
|
|
355
|
+
- Design spec: [`docs/superpowers/specs/2026-04-29-safe-result-api-design.md`](./superpowers/specs/2026-04-29-safe-result-api-design.md)
|
|
356
|
+
- Client types and interfaces: `CLAUDE.md` — "Key Files" → `src/client/`
|
|
357
|
+
- Agent config patterns and anti-patterns: `agent_config/claude-code/skills/ts-procedures/patterns.md` and `anti-patterns.md`
|