ts-procedures 6.2.0 → 7.0.0-beta.0
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 +215 -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/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 +15 -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 +96 -3
- package/build/codegen/bundle-size.test.d.ts +1 -0
- package/build/codegen/bundle-size.test.js +68 -0
- package/build/codegen/bundle-size.test.js.map +1 -0
- package/build/codegen/e2e.test.js +103 -1
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-client-runtime.js +7 -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 +94 -26
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-scope.test.js +297 -2
- 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/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 +39 -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 +112 -3
- package/src/codegen/bundle-size.test.ts +74 -0
- package/src/codegen/e2e.test.ts +108 -1
- package/src/codegen/emit-client-runtime.test.ts +7 -2
- package/src/codegen/emit-client-runtime.ts +7 -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 +324 -2
- package/src/codegen/emit-scope.ts +98 -36
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# Safe Result API & Normalized Client Errors — Design
|
|
2
|
+
|
|
3
|
+
Date: 2026-04-29
|
|
4
|
+
Branch: TBD (next major)
|
|
5
|
+
Status: design
|
|
6
|
+
|
|
7
|
+
## Goal
|
|
8
|
+
|
|
9
|
+
Eliminate the "what type is `err`?" problem in catch blocks of generated client code by:
|
|
10
|
+
|
|
11
|
+
1. **Normalizing** every error that can be raised by the client into a known set of framework error classes (network, timeout, abort, http, parse, usage). Today the client throws a mix of its own classes (`ClientRequestError`, `ClientPathParamError`) and raw platform errors (`TypeError`, `DOMException`); the normalized layer means consumers only ever see framework classes from the throwing path.
|
|
12
|
+
2. Adding a **`.safe()` sibling form** on every RPC and API callable that returns a discriminated `Result<T, E>` instead of throwing. Consumers narrow via the `kind` discriminant; per-route typed errors (declared via `route.errors`) come through under `kind: 'typed'`; framework errors come through under their own kinds (`'http'`, `'network'`, `'timeout'`, `'aborted'`, `'usage'`, `'parse'`, `'unknown'`).
|
|
13
|
+
3. Making the `kind` set **extensible** via TypeScript declaration merging on a `ClientErrorMap` interface, paired with an adapter-provided `classifyError` runtime classifier. This mirrors the existing `RequestMeta` augmentation pattern and lets downstream apps surface their own error categories as first-class members of the `Result` union.
|
|
14
|
+
|
|
15
|
+
The throwing form remains canonical; `.safe()` is opt-in. Both forms are first-class, neither is deprecated.
|
|
16
|
+
|
|
17
|
+
## Non-goals
|
|
18
|
+
|
|
19
|
+
- **Streams.** `TypedStream` mixes async iteration, a `.result` promise, and mid-stream SSE errors. Bolting `Result` on top is messy and out of scope. Streams keep the throwing form. (Stream callables benefit from the normalization layer for pre-stream errors — same classifier — but no `.safe` sibling is emitted on streams.)
|
|
20
|
+
- **Per-client generic error map.** Considered and rejected during brainstorming. Module augmentation is the single supported extension mechanism. If apps with multiple clients needing distinct maps materialize, a per-client generic can be added later (additive, non-breaking).
|
|
21
|
+
- **Result-style API wrappers for hooks or adapters.** Hooks still throw or resolve normally. The classifier runs at the `executeCall` boundary only.
|
|
22
|
+
- **Auto-retry, circuit breaking, backoff.** Out of scope. The normalized error categories make these cleaner to implement at consumer layer, but the framework does not ship them.
|
|
23
|
+
- **Renaming `ClientPathParamError` or `ClientStreamError`.** They keep their names; only `ClientRequestError` is renamed (see below).
|
|
24
|
+
|
|
25
|
+
## Output shape
|
|
26
|
+
|
|
27
|
+
### What the downstream dev writes
|
|
28
|
+
|
|
29
|
+
Throwing form (unchanged surface, normalized error classes):
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
try {
|
|
33
|
+
const response = await client.records.DownloadRecord(params, { timeout: 60_000 })
|
|
34
|
+
if (response.pdfBase64) downloadBase64AsPdf(...)
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (err instanceof ApiErrors.UseCaseError) Alerts.error(err.body.message)
|
|
37
|
+
else if (err instanceof ClientHttpError) Alerts.error(`Server ${err.status}`)
|
|
38
|
+
else if (err instanceof ClientTimeoutError) Alerts.error('Timed out')
|
|
39
|
+
else if (err instanceof ClientNetworkError) Alerts.error('Network error')
|
|
40
|
+
else if (err instanceof ClientAbortError) { /* user cancelled */ }
|
|
41
|
+
else throw err // programmer/framework bug
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Safe form (new):
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const r = await client.records.DownloadRecord.safe(params, { timeout: 60_000 })
|
|
49
|
+
|
|
50
|
+
if (r.ok) {
|
|
51
|
+
if (r.value.pdfBase64) downloadBase64AsPdf(...)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
switch (r.kind) {
|
|
56
|
+
case 'typed':
|
|
57
|
+
if (r.error instanceof ApiErrors.UseCaseError) Alerts.error(r.error.body.message)
|
|
58
|
+
break
|
|
59
|
+
case 'http': Alerts.error(`Server ${r.error.status}`); break
|
|
60
|
+
case 'network': Alerts.error('Network error'); break
|
|
61
|
+
case 'timeout': Alerts.error('Timed out'); break
|
|
62
|
+
case 'aborted': break // user cancelled
|
|
63
|
+
case 'rateLimited': Alerts.error(`Retry in ${r.error.retryAfter}s`); break // app-defined
|
|
64
|
+
case 'parse':
|
|
65
|
+
case 'usage': throw r.error // programmer/framework bug — fail loud
|
|
66
|
+
case 'unknown': Alerts.error('Unknown error'); console.error(r.error); break
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Generated callable signature (RPC and API)
|
|
71
|
+
|
|
72
|
+
Today (unchanged):
|
|
73
|
+
```ts
|
|
74
|
+
DownloadRecord(params: Records.DownloadRecord.Params, options?: ProcedureCallOptions): Promise<Records.DownloadRecord.Response>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Added: a `.safe` property on the same function value:
|
|
78
|
+
```ts
|
|
79
|
+
DownloadRecord.safe(
|
|
80
|
+
params: Records.DownloadRecord.Params,
|
|
81
|
+
options?: ProcedureCallOptions
|
|
82
|
+
): Promise<Result<Records.DownloadRecord.Response, Records.DownloadRecord.Errors>>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The throwing form and `.safe` share the same params, options, descriptor, hooks, classifier, and adapter — only the failure presentation differs.
|
|
86
|
+
|
|
87
|
+
### `Result` type derivation
|
|
88
|
+
|
|
89
|
+
Defined in `src/client/types.ts` (and bundled into `_types.ts` in self-contained mode):
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
export type Result<T, ETyped> =
|
|
93
|
+
| { ok: true; value: T }
|
|
94
|
+
| { ok: false; kind: 'typed'; error: ETyped }
|
|
95
|
+
| FrameworkFailure
|
|
96
|
+
|
|
97
|
+
type FrameworkFailure = {
|
|
98
|
+
[K in keyof ClientErrorMap]: { ok: false; kind: K; error: ClientErrorMap[K] }
|
|
99
|
+
}[keyof ClientErrorMap]
|
|
100
|
+
|
|
101
|
+
// Default (augmentable) map — devs add keys via declaration merging.
|
|
102
|
+
export interface ClientErrorMap {
|
|
103
|
+
http: ClientHttpError
|
|
104
|
+
network: ClientNetworkError
|
|
105
|
+
timeout: ClientTimeoutError
|
|
106
|
+
aborted: ClientAbortError
|
|
107
|
+
parse: ClientParseError
|
|
108
|
+
usage: ClientPathParamError
|
|
109
|
+
unknown: unknown
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Augmentation example (downstream app code, written once):
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
declare module 'ts-procedures/client' {
|
|
117
|
+
interface ClientErrorMap {
|
|
118
|
+
rateLimited: MyRateLimitError
|
|
119
|
+
paymentRequired: MyPaymentError
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
(For self-contained generated clients, the augmentation target is `./generated/_types` instead — same pattern as `RequestMeta` today.)
|
|
125
|
+
|
|
126
|
+
After augmentation, `Result`'s union expands automatically; every `.safe()` call narrows the new kinds with no per-call type ceremony and no codegen change.
|
|
127
|
+
|
|
128
|
+
**`'typed'` is reserved.** Augmentation cannot register a `kind: 'typed'` because the typed-arm is part of `Result<T, ETyped>` itself, not `ClientErrorMap`. Attempting `interface ClientErrorMap { typed: ... }` produces a confusing TS error about overlapping discriminants — which is the desired behavior (don't do it), but the docs page calls this out so adventurous consumers don't have to discover it the hard way.
|
|
129
|
+
|
|
130
|
+
## Architectural decisions
|
|
131
|
+
|
|
132
|
+
### 1. Normalize platform errors at the `executeCall` boundary
|
|
133
|
+
|
|
134
|
+
The classifier wraps the adapter call in `executeCall` (and the equivalent stream pre-error path in `executeStream`). Raw platform errors (`TypeError`, `DOMException`) are caught and translated to framework classes. The classifier composition is:
|
|
135
|
+
|
|
136
|
+
1. **Adapter-provided `classifyError`** runs first if present. Returns `{ kind, error }` or `null` (fall through).
|
|
137
|
+
2. **Default classifier** runs second. Recognizes `TypeError` from fetch → `ClientNetworkError`; `DOMException` with `name: 'AbortError'` + timeout-signal-aborted → `ClientTimeoutError`; `DOMException` with `name: 'AbortError'` + user-signal-aborted → `ClientAbortError`; anything else → `null`.
|
|
138
|
+
3. **Fallthrough** — anything still unclassified is wrapped as `kind: 'unknown'` with the raw error attached.
|
|
139
|
+
|
|
140
|
+
The default classifier is exported (`defaultClassifyError`) so adapter authors can compose deliberately:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
const adapter = createFetchAdapter({
|
|
144
|
+
classifyError: (e) => myClassify(e) ?? defaultClassifyError(e),
|
|
145
|
+
})
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Where each `kind` originates** — the `executeCall` flow has three distinct exit paths to a non-`ok` Result. The classifier only runs on the second.
|
|
149
|
+
|
|
150
|
+
| # | Stage | Source of failure | Resulting kind(s) | Notes |
|
|
151
|
+
|---|---|---|---|---|
|
|
152
|
+
| 1 | **Pre-adapter** | `buildAdapterRequest` synchronously throws (e.g. missing path param) | `'usage'` (`ClientPathParamError`) | Bypasses classifier and `onError` hook entirely. `executeSafeCall` catches it at the outer try and maps to `kind: 'usage'`. The throwing form propagates as today. |
|
|
153
|
+
| 2 | **Adapter throws** | `adapter.request()` rejects (network failure, abort, anything custom) | `'network'` / `'timeout'` / `'aborted'` / custom kinds / `'unknown'` | Classifier runs in the try/catch around `adapter.request()`. `onError` hook receives the *normalized* error (post-classification). |
|
|
154
|
+
| 3 | **Adapter returns non-2xx** | `response.status < 200 \|\| >= 300` | `'typed'` (registry match) or `'http'` (no match → `ClientHttpError`) | Classifier does *not* run on this path. The registry-or-fallback dispatch in `call.ts` is the sole source of `'typed'` and `'http'` kinds. |
|
|
155
|
+
|
|
156
|
+
The `'typed'` arm is **only** reachable via path 3. The framework-failure kinds (`'network'`, `'timeout'`, `'aborted'`, `'unknown'`, custom) are **only** reachable via path 2. The `'usage'` kind is **only** reachable via path 1. There is no overlap; each kind has exactly one source location in the runtime.
|
|
157
|
+
|
|
158
|
+
### 2. Distinguishing timeout from user-abort
|
|
159
|
+
|
|
160
|
+
`AbortSignal.any([timeoutSignal, userSignal])` loses provenance — when `aborted` fires, the combined signal alone can't tell us which input triggered it. `executeCall` keeps references to both signals locally and inspects `timeoutSignal?.aborted` first when an `AbortError` lands; if true, classify as timeout, else as user-abort.
|
|
161
|
+
|
|
162
|
+
This logic lives in `defaultClassifyError`, but it needs the timeout signal as input. Resolution: classifier signature takes `(raw, ctx)` where `ctx` carries `{ timeoutSignal?, userSignal? }`:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
type ErrorClassifier = (
|
|
166
|
+
raw: unknown,
|
|
167
|
+
ctx: { timeoutSignal?: AbortSignal; userSignal?: AbortSignal }
|
|
168
|
+
) => { kind: string; error: Error } | null
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The classifier's return contract is `error: Error` — every classified output is a real `Error` subclass (the framework class). Non-`Error` throws (e.g. a hook that threw a string) are *not* classified; they fall through to `kind: 'unknown'` with the raw value preserved as-is. `kind: 'unknown'` is the *only* path that produces a non-`Error` `error` field, and it's the only `ClientErrorMap` entry typed as `unknown` rather than a class.
|
|
172
|
+
|
|
173
|
+
### 3. New error class taxonomy
|
|
174
|
+
|
|
175
|
+
| Class | Replaces / new | `kind` | Notes |
|
|
176
|
+
|---|---|---|---|
|
|
177
|
+
| `ClientHttpError` | renames `ClientRequestError` | `'http'` | Same shape (`status`, `headers`, `body`, `procedureName`, `scope`). Old name re-exported as a deprecated alias for one minor cycle? See migration. |
|
|
178
|
+
| `ClientNetworkError` | new | `'network'` | Wraps the original `TypeError`; carries the raw cause. |
|
|
179
|
+
| `ClientTimeoutError` | new | `'timeout'` | Carries `timeoutMs`. |
|
|
180
|
+
| `ClientAbortError` | new | `'aborted'` | Carries the `AbortSignal.reason` if available. |
|
|
181
|
+
| `ClientParseError` | new | `'parse'` | Adapter-reported body parse failure (not currently raised; reserved for adapters that want to flag unparseable bodies). |
|
|
182
|
+
| `ClientPathParamError` | unchanged | `'usage'` | Existing class, kind added. |
|
|
183
|
+
|
|
184
|
+
All extend `Error`. All have `readonly procedureName` and `readonly scope` for telemetry. **Every framework class accepts an optional `cause` constructor argument and assigns it to the standard `Error.cause` property** — explicit contract so adapter authors and telemetry consumers can rely on `error.cause` to recover the underlying platform error (e.g. the original `TypeError` that produced a `ClientNetworkError`). The default classifier always populates `cause` when wrapping a platform error; consumer-provided classifiers SHOULD do the same and the docs page calls this out.
|
|
185
|
+
|
|
186
|
+
### 4. `.safe` is a sibling property on the callable, not a separate namespace
|
|
187
|
+
|
|
188
|
+
Each generated callable becomes the throwing function plus a `.safe` property:
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
// in emit-scope.ts output
|
|
192
|
+
function DownloadRecord(params, options) { return client.call({...}, options) }
|
|
193
|
+
DownloadRecord.safe = function (params, options) { return client.safeCall({...}, options) }
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
In TypeScript this is expressed via an intersection type at codegen time, or by annotating the binding with a generated interface. (Implementation detail for the writing-plans phase.) Discoverability wins: hover on `DownloadRecord` shows `.safe`.
|
|
197
|
+
|
|
198
|
+
### 5. Codegen omits the `'typed'` arm when the route declares no errors
|
|
199
|
+
|
|
200
|
+
A naive emission of `Result<Response, never>` for routes without typed errors leaves `{ ok: false; kind: 'typed'; error: never }` in the union. TypeScript does not collapse `never`-payload arms in hover output — the consumer sees an unreachable five-line arm cluttering every IDE tooltip. To prevent this, codegen branches:
|
|
201
|
+
|
|
202
|
+
- Route has at least one entry in `route.errors` → `Result<Response, RouteErrors>` (full union, including `'typed'` arm).
|
|
203
|
+
- Route has no errors declared → `ResultNoTyped<Response>` (`{ ok: true; value: T } | FrameworkFailure`, no `'typed'` arm).
|
|
204
|
+
|
|
205
|
+
Both type aliases live in `src/client/types.ts` (and the bundled `_types.ts` in self-contained mode). The codegen change in `emit-scope.ts` selects which alias to emit per route based on the presence of `route.errors`. Hover output stays clean either way.
|
|
206
|
+
|
|
207
|
+
### 6. `.safe` is RPC and API only
|
|
208
|
+
|
|
209
|
+
Streams keep the throwing form. The classifier still normalizes their pre-stream errors (so `executeStream` throws `ClientHttpError` etc. instead of raw `DOMException`), but `TypedStream.result` and async iteration stay as-is.
|
|
210
|
+
|
|
211
|
+
### 7. `ClientErrorMap` augmentation matches `RequestMeta` precedent
|
|
212
|
+
|
|
213
|
+
Same module name (`ts-procedures/client` for direct consumers, `./generated/_types` for self-contained), same `interface X { ... }` declaration-merging mechanism, same documentation pattern. Devs who already learned the `RequestMeta` augmentation idiom transfer it directly.
|
|
214
|
+
|
|
215
|
+
### 8. `unknown` is an explicit kind, not a leak
|
|
216
|
+
|
|
217
|
+
Anything the classifier can't categorize falls through to `kind: 'unknown'` with `error: unknown`. This is a **deliberate** discriminant — consumers handling all kinds exhaustively get a single fallthrough branch for true edge cases (a hook threw something weird, a custom adapter raised something nobody declared). It is *not* a synonym for "error we forgot to classify" — every category we ship default coverage for is represented as its own kind.
|
|
218
|
+
|
|
219
|
+
### 9. Hooks see normalized errors
|
|
220
|
+
|
|
221
|
+
`onError` hook receives the classified framework error in `ctx.error`, not the raw platform exception. This is a behavior change vs. today (today the hook sees whatever the adapter threw — typically raw `TypeError`/`DOMException`). Documented in migration notes.
|
|
222
|
+
|
|
223
|
+
The hook ordering is unchanged: `onError` runs after the adapter throws, before `executeCall` re-throws / the `.safe` wrapper catches.
|
|
224
|
+
|
|
225
|
+
### 10. `.safe` invokes `onError` (cross-cutting telemetry)
|
|
226
|
+
|
|
227
|
+
`onError` runs on every failure regardless of whether the consumer used the throwing form or the `.safe` form. The hook is for cross-cutting concerns (logging, tracing, metrics) which want to observe all failures uniformly; making it conditional on call style would force telemetry consumers to wire two paths.
|
|
228
|
+
|
|
229
|
+
**Known consequence — retries-via-`.safe`.** A consumer using `.safe` to drive a retry loop will get one `onError` invocation per attempt, including attempts that the application considers expected/recoverable. This is by design for v1 (telemetry should see all attempts; the *application* knows what's "expected", not the framework). The FAQ in `docs/client-error-handling.md` documents this and shows the suppression pattern: the consumer attaches a per-call `onError` that no-ops, or uses the `meta` field to flag "this is a retry, ignore" inside their global `onError`. A dedicated config knob is deliberately not added in v1; if the pattern proves heavy enough that consumers consistently reach for the workaround, a `suppressOnErrorForSafe` adapter-level toggle can be added later (additive, non-breaking).
|
|
230
|
+
|
|
231
|
+
### 11. Implementation locus
|
|
232
|
+
|
|
233
|
+
- `src/client/errors.ts` — add `ClientNetworkError`, `ClientTimeoutError`, `ClientAbortError`, `ClientParseError`. Rename `ClientRequestError` → `ClientHttpError`.
|
|
234
|
+
- `src/client/classify-error.ts` — **new file**. Exports `defaultClassifyError`, `ErrorClassifier` type.
|
|
235
|
+
- `src/client/types.ts` — add `ClientErrorMap` interface, `Result<T, E>` type, `FrameworkFailure` derivation. Extend `ClientAdapter` with optional `classifyError`. Extend `ClientInstance` with `safeCall<TResponse, ETyped>(descriptor, options): Promise<Result<TResponse, ETyped>>`.
|
|
236
|
+
- `src/client/call.ts` — wrap adapter call in classifier; `executeCall` throws normalized errors. Add `executeSafeCall` that catches `executeCall` and returns `Result`. The generated `.safe` callable closes over `client.safeCall`, which delegates to `executeSafeCall`. Single source of truth for the safe path: `executeSafeCall`.
|
|
237
|
+
- `src/client/stream.ts` — wrap adapter stream call in classifier (pre-stream errors only). Mid-stream errors unchanged.
|
|
238
|
+
- `src/client/fetch-adapter.ts` — accept `classifyError` in config (composes with default).
|
|
239
|
+
- `src/client/index.ts` — export new classes, classifier, `Result`, `ClientErrorMap`.
|
|
240
|
+
- `src/codegen/emit-scope.ts` — emit `.safe` sibling on every RPC/API callable. Type signature uses `Result<Response, RouteErrors>`.
|
|
241
|
+
- `src/codegen/emit-index.ts` — no changes needed (factory shape unchanged).
|
|
242
|
+
- `src/codegen/targets/_shared/` — no changes (Kotlin/Swift unaffected).
|
|
243
|
+
|
|
244
|
+
## Pipeline integration
|
|
245
|
+
|
|
246
|
+
No CLI flag changes. The `.safe` form is always emitted for RPC/API routes; consumers who don't use it pay zero runtime cost (the property is a thin closure over `client.safeCall`).
|
|
247
|
+
|
|
248
|
+
For self-contained mode (`--self-contained`, the default), `_types.ts` and `_client.ts` bundle the new types and runtime. The augmentation target shifts from `ts-procedures/client` to `./generated/_types` — documented in the codegen docs alongside `RequestMeta`'s analogous note.
|
|
249
|
+
|
|
250
|
+
`_errors.ts` is unchanged.
|
|
251
|
+
|
|
252
|
+
## Migration (breaking changes)
|
|
253
|
+
|
|
254
|
+
**Major version bump (next major).**
|
|
255
|
+
|
|
256
|
+
| Change | Impact | Mitigation |
|
|
257
|
+
|---|---|---|
|
|
258
|
+
| `ClientRequestError` → `ClientHttpError` | Anyone catching `ClientRequestError` directly | Re-export `ClientRequestError` as a deprecated alias of `ClientHttpError` for one minor cycle. Console-warn on import? (Probably no — pure type re-export is silent. Documentation only.) |
|
|
259
|
+
| Raw `DOMException`/`TypeError` no longer reach consumer catch blocks | Anyone catching `DOMException` directly to detect aborts/timeouts | Migration guide shows `instanceof ClientTimeoutError` / `ClientAbortError` pattern. Old pattern keeps working only if the consumer's own code does the check before our throw — which by definition won't happen since we re-throw normalized. |
|
|
260
|
+
| `onError` hook sees normalized errors | Anyone inspecting `ctx.error` for `DOMException` / `TypeError` shape | Migration guide. The normalized class wraps the raw cause (`error.cause`), so consumers can still drill in if needed. |
|
|
261
|
+
| `ClientAdapter` interface gains optional `classifyError` | None for default fetch adapter consumers; custom adapter authors must consider whether to provide one | Optional field, no required change. |
|
|
262
|
+
|
|
263
|
+
No changes to: schema authoring, server-side procedure definitions, `Procedures()` factory, hook order, request descriptors, generated index/factory shape, Kotlin/Swift targets.
|
|
264
|
+
|
|
265
|
+
## Tests
|
|
266
|
+
|
|
267
|
+
### Runtime (`src/client/`)
|
|
268
|
+
|
|
269
|
+
1. **`classify-error.test.ts`** — default classifier: `TypeError` → `ClientNetworkError`; `AbortError` + timeout-signal-aborted → `ClientTimeoutError`; `AbortError` + user-signal-aborted → `ClientAbortError`; both signals fired → timeout wins (deterministic precedence); unknown error → `null` (fallthrough).
|
|
270
|
+
2. **`call.test.ts`** (extend) — `executeCall` throws normalized classes for each platform error category. Adapter-provided classifier runs first; default runs second; unclassified raises original error wrapped as a generic `Error` (NOT swallowed). Hooks see normalized error.
|
|
271
|
+
3. **`safe-call.test.ts`** (new) — every kind in `ClientErrorMap` produces the corresponding `Result` shape. `kind: 'typed'` carries the registry-dispatched typed error. `ok: true` for 2xx with body. Adapter classifier custom kinds round-trip.
|
|
272
|
+
4. **`fetch-adapter.test.ts`** (extend) — `classifyError` config composes with default (custom-then-default order).
|
|
273
|
+
5. **`stream.test.ts`** (extend) — pre-stream errors are classified; mid-stream errors are not affected.
|
|
274
|
+
|
|
275
|
+
### Codegen (`src/codegen/`)
|
|
276
|
+
|
|
277
|
+
6. **`emit-scope.test.ts`** (extend) — RPC and API callables emit a `.safe` property with the correct `Result<Response, RouteErrors>` signature. Streams do not emit `.safe`. Routes without typed errors emit `Result<Response, never>` (the `kind: 'typed'` branch is unreachable but type-valid).
|
|
278
|
+
7. **Golden file integration** — update `users-golden.ts` (or equivalent ts-target fixture) with the new `.safe` siblings.
|
|
279
|
+
8. **TypeScript compilation test** — verify generated output type-checks under `tsc --noEmit`. Existing test, will catch regressions.
|
|
280
|
+
|
|
281
|
+
### Augmentation (manual / docs example)
|
|
282
|
+
|
|
283
|
+
9. **`augment-error-map.test.ts`** (new) — a sample `declare module` block in a `.test-d.ts` file using `tsd` or `expectType` to verify augmented kinds appear in the `Result` union and narrow correctly.
|
|
284
|
+
|
|
285
|
+
10. **Bundle-size budget test** (new) — generate a 100-route fixture, run the codegen, minify the output (`esbuild --minify`), and assert the per-route byte cost added by the `.safe` sibling is within budget. The doc claims "zero runtime cost" but each callable now wraps two functions in a binding object that prevents tree-shaking; rough estimate ~100 bytes per route post-minify. Initial budget: assert the *delta* between this branch and the prior major's output stays below **200 bytes per route** for the 100-route fixture. If the test fails or the budget is wrong, the budget can be revised but the test itself stays as a regression guard.
|
|
286
|
+
|
|
287
|
+
## Documentation
|
|
288
|
+
|
|
289
|
+
1. **`docs/client-error-handling.md`** — new file. Covers:
|
|
290
|
+
- The throwing form: framework error class taxonomy + narrowing pattern.
|
|
291
|
+
- The `.safe` form: when to use, full example with exhaustive switch.
|
|
292
|
+
- Custom error categories: `ClientErrorMap` augmentation + adapter `classifyError`.
|
|
293
|
+
- Migration from `ClientRequestError` / `DOMException` catches.
|
|
294
|
+
2. **`CLAUDE.md`** — add a paragraph in the Client section pointing at the new file. Update the "Important Patterns" list to mention the normalized error taxonomy and `.safe` form.
|
|
295
|
+
3. **`agent_config/`** — update the AI-assistant skills:
|
|
296
|
+
- `claude-code/skills/ts-procedures/patterns.md` — add a "Handling errors in client code" section showing both forms.
|
|
297
|
+
- `claude-code/skills/ts-procedures/anti-patterns.md` — add "Don't catch raw DOMException/TypeError" anti-pattern, point at framework classes.
|
|
298
|
+
- `copilot/copilot-instructions.md` and `cursor/cursorrules` — same patterns, condensed.
|
|
299
|
+
4. The TS-target consumer guide currently lives in `CLAUDE.md` and the new `docs/client-error-handling.md` — no separate `docs/codegen-ts.md` file exists today, and creating one is out of scope for this change. The `.safe` form is documented in `docs/client-error-handling.md` (item 1) with a back-reference from the `CLAUDE.md` Client section (item 2).
|
|
300
|
+
5. **CHANGELOG / release notes** — major bump entry covering the rename, the new classes, the `.safe` form, the augmentation pattern.
|
|
301
|
+
|
|
302
|
+
## Open questions
|
|
303
|
+
|
|
304
|
+
These are the remaining design decisions to resolve before implementation. Each has a recommended default in parentheses; called out explicitly so the writing-plans phase makes them explicit choices rather than implicit ones.
|
|
305
|
+
|
|
306
|
+
1. **Does the deprecated `ClientRequestError` alias ship?** (Recommended: **yes**, for one minor release after the major bump. Pure type re-export, zero runtime cost.)
|
|
307
|
+
2. **Does `ClientParseError` get raised by the default fetch adapter today?** (Recommended: **no**, the default adapter falls back to text/null on parse failure — current behavior. The class ships so adapter authors who want stricter parsing can use it. Keeps scope tight.)
|
|
308
|
+
3. **What does `executeStream`'s pre-stream error path look like?** (Recommended: same classifier composition as `executeCall`. Mid-stream errors unchanged. No `.safe` on stream callables.)
|
|
309
|
+
4. **Is `ClientErrorMap['unknown']` typed as `unknown` or as `Error`?** (Recommended: **`unknown`**, because by definition nobody knows what raw thing landed there. Forcing `Error` would require wrapping non-`Error` throws and lose information.)
|
|
310
|
+
|
|
311
|
+
## Decision log
|
|
312
|
+
|
|
313
|
+
| Decision | Choice | Rationale |
|
|
314
|
+
|---|---|---|
|
|
315
|
+
| Extension mechanism | Module augmentation on `ClientErrorMap` | Matches `RequestMeta` precedent; zero codegen complexity; zero per-call ceremony. Per-client generic rejected as over-engineering for the multi-client case. |
|
|
316
|
+
| `'typed'` discriminant | Reserved; not part of `ClientErrorMap` | Keeps the typed-arm tied to per-route `ETyped` carrier rather than the global map. Augmentation collisions impossible by construction. |
|
|
317
|
+
| `.safe` shape | Sibling property on each callable | Most discoverable; hover shows both forms; only form that carries the per-route `Errors` union typed correctly. Parallel namespace and options-toggle rejected. |
|
|
318
|
+
| Hover cleanliness for routes without errors | Codegen omits `'typed'` arm via `ResultNoTyped<T>` | TS does not collapse `never`-payload arms in hover; explicit alias keeps tooltips clean. |
|
|
319
|
+
| Stream coverage | Out of scope for `.safe` | Stream failure surface is a three-way split (pre-stream, mid-stream, completion); `Result` doesn't fit cleanly. Classifier still normalizes pre-stream errors. |
|
|
320
|
+
| Throwing form | Stays canonical, not deprecated | Most consumers prefer try/catch; `.safe` is opt-in for sites that want exhaustive failure handling. Both forms first-class. |
|
|
321
|
+
| Default classifier composition | Custom-then-default | Adapter authors can intercept early; default is the floor. `defaultClassifyError` exported so authors can opt back in deliberately. |
|
|
322
|
+
| Timeout vs. user-abort distinction | Local refs in `executeCall`, passed to classifier via `ctx` | `AbortSignal.any` loses provenance; refs are the only way to recover it. |
|
|
323
|
+
| `onError` invocation by `.safe` | Yes — fires regardless of call style | Telemetry is cross-cutting; conditional firing forces dual wiring. Retry-via-`.safe` consumers suppress at the call-site (per-call `onError` no-op or `meta.isRetry` flag), not via framework toggle. |
|
|
324
|
+
| Cause chain | Every framework class accepts `cause` constructor arg, sets `Error.cause` | Adapter authors and telemetry consumers rely on `error.cause` to recover the underlying platform error. Explicit contract, default classifier always populates. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ts-procedures",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0-beta.0",
|
|
4
4
|
"description": "A TypeScript RPC framework that creates type-safe, schema-validated procedure calls with a single function definition. Define your procedures once and get full type inference, runtime validation, and framework integration hooks.",
|
|
5
5
|
"main": "build/exports.js",
|
|
6
6
|
"types": "build/exports.d.ts",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from 'vitest'
|
|
2
|
+
import type { Result } from './types.js'
|
|
3
|
+
|
|
4
|
+
class RateLimitError extends Error {
|
|
5
|
+
constructor(public retryAfter: number) {
|
|
6
|
+
super('rate limited')
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
declare module './types.js' {
|
|
11
|
+
interface ClientErrorMap {
|
|
12
|
+
rateLimited: RateLimitError
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('ClientErrorMap augmentation', () => {
|
|
17
|
+
it('adds rateLimited kind to Result', () => {
|
|
18
|
+
type R = Result<number, Error>
|
|
19
|
+
type RateLimited = Extract<R, { kind: 'rateLimited' }>
|
|
20
|
+
expectTypeOf<RateLimited['error']>().toEqualTypeOf<RateLimitError>()
|
|
21
|
+
})
|
|
22
|
+
})
|
package/src/client/call.test.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest'
|
|
2
2
|
import { executeCall } from './call.js'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
ClientRequestError,
|
|
5
|
+
ClientNetworkError,
|
|
6
|
+
ClientTimeoutError,
|
|
7
|
+
ClientAbortError,
|
|
8
|
+
ClientHttpError,
|
|
9
|
+
} from './errors.js'
|
|
4
10
|
import type {
|
|
5
11
|
ClientAdapter,
|
|
6
12
|
AdapterRequest,
|
|
@@ -333,3 +339,61 @@ describe('executeCall', () => {
|
|
|
333
339
|
expect(observedMeta).toEqual({ traceId: 'override', attempt: 1 })
|
|
334
340
|
})
|
|
335
341
|
})
|
|
342
|
+
|
|
343
|
+
describe('executeCall classifier integration', () => {
|
|
344
|
+
it('throws ClientNetworkError when adapter throws TypeError', async () => {
|
|
345
|
+
const adapter: ClientAdapter = {
|
|
346
|
+
request: vi.fn(async () => { throw new TypeError('Failed to fetch') }),
|
|
347
|
+
stream: vi.fn(async () => { throw new Error('n/a') }),
|
|
348
|
+
}
|
|
349
|
+
await expect(run({ adapter })).rejects.toBeInstanceOf(ClientNetworkError)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('throws ClientTimeoutError when adapter rejects with AbortError on timeout signal', async () => {
|
|
353
|
+
// Simulate fetch-style behavior: adapter waits on the signal and rejects
|
|
354
|
+
// with AbortError when it fires. Real fetch() throws AFTER the timeout
|
|
355
|
+
// signal aborts, so the classifier sees `timeoutSignal.aborted === true`.
|
|
356
|
+
const adapter: ClientAdapter = {
|
|
357
|
+
request: vi.fn(async (req) => {
|
|
358
|
+
await new Promise<void>((_resolve, reject) => {
|
|
359
|
+
req.signal?.addEventListener(
|
|
360
|
+
'abort',
|
|
361
|
+
() => reject(new DOMException('aborted', 'AbortError')),
|
|
362
|
+
{ once: true },
|
|
363
|
+
)
|
|
364
|
+
})
|
|
365
|
+
throw new Error('unreachable')
|
|
366
|
+
}),
|
|
367
|
+
stream: vi.fn(async () => { throw new Error('n/a') }),
|
|
368
|
+
}
|
|
369
|
+
await expect(
|
|
370
|
+
run({ adapter, options: { timeout: 1 } })
|
|
371
|
+
).rejects.toBeInstanceOf(ClientTimeoutError)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('uses adapter-provided classifier first', async () => {
|
|
375
|
+
class CustomError extends Error { readonly name = 'CustomError' }
|
|
376
|
+
const adapter: ClientAdapter = {
|
|
377
|
+
request: vi.fn(async () => { throw new TypeError('x') }),
|
|
378
|
+
stream: vi.fn(async () => { throw new Error('n/a') }),
|
|
379
|
+
classifyError: () => ({ kind: 'custom', error: new CustomError('classified') }),
|
|
380
|
+
}
|
|
381
|
+
await expect(run({ adapter })).rejects.toBeInstanceOf(CustomError)
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it('still throws ClientHttpError for non-2xx responses (registry path unchanged)', async () => {
|
|
385
|
+
const adapter = makeAdapter({ status: 500, body: { message: 'oops' } })
|
|
386
|
+
await expect(run({ adapter })).rejects.toBeInstanceOf(ClientHttpError)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('hooks see the normalized error, not the raw TypeError', async () => {
|
|
390
|
+
const seen: unknown[] = []
|
|
391
|
+
const adapter: ClientAdapter = {
|
|
392
|
+
request: vi.fn(async () => { throw new TypeError('x') }),
|
|
393
|
+
stream: vi.fn(async () => { throw new Error('n/a') }),
|
|
394
|
+
}
|
|
395
|
+
const hooks = { onError: ({ error }: { error: unknown }) => { seen.push(error) } }
|
|
396
|
+
await expect(run({ adapter, hooks })).rejects.toBeInstanceOf(ClientNetworkError)
|
|
397
|
+
expect(seen[0]).toBeInstanceOf(ClientNetworkError)
|
|
398
|
+
})
|
|
399
|
+
})
|
package/src/client/call.ts
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
import { buildAdapterRequest } from './request-builder.js'
|
|
2
2
|
import { runBeforeRequest, runAfterResponse, runOnError } from './hooks.js'
|
|
3
3
|
import { applyRequestOptions, resolveBasePath } from './resolve-options.js'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ClientHttpError,
|
|
6
|
+
ClientNetworkError,
|
|
7
|
+
ClientTimeoutError,
|
|
8
|
+
ClientAbortError,
|
|
9
|
+
ClientParseError,
|
|
10
|
+
ClientPathParamError,
|
|
11
|
+
} from './errors.js'
|
|
5
12
|
import { dispatchTypedError } from './error-dispatch.js'
|
|
13
|
+
import { defaultClassifyError } from './classify-error.js'
|
|
6
14
|
import type {
|
|
7
15
|
ClientAdapter,
|
|
8
16
|
ClientHooks,
|
|
9
17
|
CallDescriptor,
|
|
18
|
+
ClassifyErrorContext,
|
|
10
19
|
ErrorRegistry,
|
|
11
20
|
ProcedureCallDefaults,
|
|
12
21
|
ProcedureCallOptions,
|
|
22
|
+
Result,
|
|
23
|
+
ClientErrorMap,
|
|
24
|
+
FrameworkFailure,
|
|
13
25
|
} from './types.js'
|
|
14
26
|
|
|
15
27
|
export interface ExecuteCallConfig {
|
|
@@ -32,7 +44,7 @@ export interface ExecuteCallConfig {
|
|
|
32
44
|
* 4. Call adapter.request()
|
|
33
45
|
* 5. On adapter error: run onError hooks, re-throw
|
|
34
46
|
* 6. Run onAfterResponse hooks (may mutate response.status to swallow errors)
|
|
35
|
-
* 7. If response status is non-2xx: throw
|
|
47
|
+
* 7. If response status is non-2xx: throw ClientHttpError
|
|
36
48
|
* 8. Return response.body as TResponse
|
|
37
49
|
*/
|
|
38
50
|
export async function executeCall<TResponse>(config: ExecuteCallConfig): Promise<TResponse> {
|
|
@@ -43,7 +55,9 @@ export async function executeCall<TResponse>(config: ExecuteCallConfig): Promise
|
|
|
43
55
|
let request = buildAdapterRequest(descriptor, resolvedBasePath)
|
|
44
56
|
|
|
45
57
|
// 2. Apply request-level options (headers, signal, timeout, meta)
|
|
46
|
-
|
|
58
|
+
const applied = applyRequestOptions(request, defaults, options)
|
|
59
|
+
request = applied.request
|
|
60
|
+
const signalSources = applied.signalSources
|
|
47
61
|
|
|
48
62
|
// 3. Run before-request hooks — they may further mutate the request
|
|
49
63
|
const beforeCtx = await runBeforeRequest(
|
|
@@ -57,14 +71,36 @@ export async function executeCall<TResponse>(config: ExecuteCallConfig): Promise
|
|
|
57
71
|
let response
|
|
58
72
|
try {
|
|
59
73
|
response = await adapter.request(request)
|
|
60
|
-
} catch (
|
|
61
|
-
// 5. On adapter error:
|
|
74
|
+
} catch (rawErr) {
|
|
75
|
+
// 5. On adapter error: classify (adapter > default > fallthrough), then run
|
|
76
|
+
// onError hooks with the normalized error, then throw.
|
|
77
|
+
const classifyCtx: ClassifyErrorContext = {
|
|
78
|
+
procedureName: descriptor.name,
|
|
79
|
+
scope: descriptor.scope,
|
|
80
|
+
timeoutSignal: signalSources.timeoutSignal,
|
|
81
|
+
userSignal: signalSources.userSignal,
|
|
82
|
+
timeoutMs: signalSources.timeoutMs,
|
|
83
|
+
}
|
|
84
|
+
const classified =
|
|
85
|
+
adapter.classifyError?.(rawErr, classifyCtx) ??
|
|
86
|
+
defaultClassifyError(rawErr, classifyCtx)
|
|
87
|
+
// Tag the classified error so executeSafeCall can identify its kind without
|
|
88
|
+
// re-classifying. Default kinds are recognized via instanceof in
|
|
89
|
+
// classifyThrownError; only custom adapter kinds need an explicit tag.
|
|
90
|
+
if (classified) {
|
|
91
|
+
const defaultKinds = new Set(['network', 'timeout', 'aborted', 'parse'])
|
|
92
|
+
if (!defaultKinds.has(classified.kind)) {
|
|
93
|
+
;(classified.error as unknown as { __tsProceduresKind?: string }).__tsProceduresKind = classified.kind
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const finalError = classified?.error ?? rawErr
|
|
97
|
+
|
|
62
98
|
await runOnError(
|
|
63
|
-
{ procedureName: descriptor.name, scope: descriptor.scope, request, error:
|
|
99
|
+
{ procedureName: descriptor.name, scope: descriptor.scope, request, error: finalError },
|
|
64
100
|
hooks,
|
|
65
101
|
options,
|
|
66
102
|
)
|
|
67
|
-
throw
|
|
103
|
+
throw finalError
|
|
68
104
|
}
|
|
69
105
|
|
|
70
106
|
// 6. Run after-response hooks — they may mutate response.status to swallow errors
|
|
@@ -81,8 +117,13 @@ export async function executeCall<TResponse>(config: ExecuteCallConfig): Promise
|
|
|
81
117
|
procedureName: descriptor.name,
|
|
82
118
|
scope: descriptor.scope,
|
|
83
119
|
})
|
|
84
|
-
if (typed)
|
|
85
|
-
|
|
120
|
+
if (typed) {
|
|
121
|
+
// Tag so executeSafeCall can distinguish typed registry errors from plain
|
|
122
|
+
// ClientHttpError without re-inspecting the registry.
|
|
123
|
+
;(typed as unknown as { __tsProceduresTyped?: boolean }).__tsProceduresTyped = true
|
|
124
|
+
throw typed
|
|
125
|
+
}
|
|
126
|
+
throw new ClientHttpError({
|
|
86
127
|
status: response.status,
|
|
87
128
|
headers: response.headers,
|
|
88
129
|
body: response.body,
|
|
@@ -94,3 +135,64 @@ export async function executeCall<TResponse>(config: ExecuteCallConfig): Promise
|
|
|
94
135
|
// 8. Return the body
|
|
95
136
|
return response.body as TResponse
|
|
96
137
|
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Wraps `executeCall` and returns a discriminated `Result` instead of throwing.
|
|
141
|
+
*
|
|
142
|
+
* Three failure-source paths map to distinct kinds:
|
|
143
|
+
* 1. Pre-adapter throw (e.g. `ClientPathParamError`) → `kind: 'usage'`
|
|
144
|
+
* 2. Adapter throw, classified → `kind: 'network' | 'timeout' | 'aborted' | <custom> | 'unknown'`
|
|
145
|
+
* 3. Adapter returns non-2xx → `kind: 'typed'` (registry match) or `kind: 'http'`
|
|
146
|
+
*
|
|
147
|
+
* `onError` hook fires on path 2 and 3 (cross-cutting telemetry); NOT on
|
|
148
|
+
* path 1 (usage errors bypass the classifier and onError entirely).
|
|
149
|
+
*/
|
|
150
|
+
export async function executeSafeCall<TResponse, ETyped = never>(
|
|
151
|
+
config: ExecuteCallConfig,
|
|
152
|
+
): Promise<Result<TResponse, ETyped>> {
|
|
153
|
+
try {
|
|
154
|
+
const value = await executeCall<TResponse>(config)
|
|
155
|
+
return { ok: true, value }
|
|
156
|
+
} catch (err) {
|
|
157
|
+
return classifyThrownError<ETyped>(err)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function classifyThrownError<ETyped>(err: unknown): Result<never, ETyped> {
|
|
162
|
+
// Path 1: pre-adapter usage error — bypasses classifier and onError
|
|
163
|
+
if (err instanceof ClientPathParamError) {
|
|
164
|
+
return { ok: false, kind: 'usage', error: err }
|
|
165
|
+
}
|
|
166
|
+
// Path 3: post-status-check typed registry match — tagged by executeCall
|
|
167
|
+
if (err instanceof Error && (err as unknown as { __tsProceduresTyped?: boolean }).__tsProceduresTyped) {
|
|
168
|
+
return { ok: false, kind: 'typed', error: err as ETyped }
|
|
169
|
+
}
|
|
170
|
+
// Path 3: non-2xx fallback (no registry match)
|
|
171
|
+
if (err instanceof ClientHttpError) {
|
|
172
|
+
return { ok: false, kind: 'http', error: err }
|
|
173
|
+
}
|
|
174
|
+
// Path 2: classifier output (already normalized by executeCall)
|
|
175
|
+
if (err instanceof ClientNetworkError) {
|
|
176
|
+
return { ok: false, kind: 'network', error: err }
|
|
177
|
+
}
|
|
178
|
+
if (err instanceof ClientTimeoutError) {
|
|
179
|
+
return { ok: false, kind: 'timeout', error: err }
|
|
180
|
+
}
|
|
181
|
+
if (err instanceof ClientAbortError) {
|
|
182
|
+
return { ok: false, kind: 'aborted', error: err }
|
|
183
|
+
}
|
|
184
|
+
if (err instanceof ClientParseError) {
|
|
185
|
+
return { ok: false, kind: 'parse', error: err }
|
|
186
|
+
}
|
|
187
|
+
// Custom adapter-classified error — tagged with its kind by executeCall.
|
|
188
|
+
// The cast is intentional: the framework knows only the default ClientErrorMap
|
|
189
|
+
// keys, but consumer-augmented kinds are valid at the consumer's site (where
|
|
190
|
+
// the augmented map is in scope). The runtime kind string is whatever the
|
|
191
|
+
// adapter classifier returned; we trust it to match a registered entry.
|
|
192
|
+
if (err instanceof Error && typeof (err as unknown as { __tsProceduresKind?: string }).__tsProceduresKind === 'string') {
|
|
193
|
+
const kind = (err as unknown as { __tsProceduresKind: string }).__tsProceduresKind
|
|
194
|
+
return { ok: false, kind: kind as keyof ClientErrorMap, error: err } as FrameworkFailure
|
|
195
|
+
}
|
|
196
|
+
// Fallthrough — unrecognized throw type (non-Error or unclassified)
|
|
197
|
+
return { ok: false, kind: 'unknown', error: err }
|
|
198
|
+
}
|