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
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
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { defaultClassifyError } from './classify-error.js'
|
|
3
|
+
import {
|
|
4
|
+
ClientNetworkError,
|
|
5
|
+
ClientTimeoutError,
|
|
6
|
+
ClientAbortError,
|
|
7
|
+
} from './errors.js'
|
|
8
|
+
|
|
9
|
+
const meta = { procedureName: 'GetUser', scope: 'users' }
|
|
10
|
+
|
|
11
|
+
describe('defaultClassifyError', () => {
|
|
12
|
+
it('classifies fetch TypeError as network', () => {
|
|
13
|
+
const result = defaultClassifyError(new TypeError('Failed to fetch'), { ...meta })
|
|
14
|
+
expect(result?.kind).toBe('network')
|
|
15
|
+
expect(result?.error).toBeInstanceOf(ClientNetworkError)
|
|
16
|
+
expect(result?.error.cause).toBeInstanceOf(TypeError)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('classifies AbortError as timeout when timeout signal fired', () => {
|
|
20
|
+
const timeoutSignal = AbortSignal.timeout(0)
|
|
21
|
+
// wait a tick for the timeout to fire
|
|
22
|
+
return new Promise<void>((resolve) => setTimeout(() => {
|
|
23
|
+
const abortErr = new DOMException('aborted', 'AbortError')
|
|
24
|
+
const result = defaultClassifyError(abortErr, { ...meta, timeoutSignal, timeoutMs: 5000 })
|
|
25
|
+
expect(result?.kind).toBe('timeout')
|
|
26
|
+
expect(result?.error).toBeInstanceOf(ClientTimeoutError)
|
|
27
|
+
expect((result?.error as ClientTimeoutError).timeoutMs).toBe(5000)
|
|
28
|
+
resolve()
|
|
29
|
+
}, 1))
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('classifies AbortError as aborted when user signal fired', () => {
|
|
33
|
+
const userController = new AbortController()
|
|
34
|
+
userController.abort('user-cancelled')
|
|
35
|
+
const abortErr = new DOMException('aborted', 'AbortError')
|
|
36
|
+
const result = defaultClassifyError(abortErr, { ...meta, userSignal: userController.signal })
|
|
37
|
+
expect(result?.kind).toBe('aborted')
|
|
38
|
+
expect(result?.error).toBeInstanceOf(ClientAbortError)
|
|
39
|
+
expect((result?.error as ClientAbortError).reason).toBe('user-cancelled')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('classifies AbortError as timeout when both fired (timeout precedence)', () => {
|
|
43
|
+
const timeoutSignal = AbortSignal.timeout(0)
|
|
44
|
+
const userController = new AbortController()
|
|
45
|
+
return new Promise<void>((resolve) => setTimeout(() => {
|
|
46
|
+
userController.abort('user')
|
|
47
|
+
const abortErr = new DOMException('aborted', 'AbortError')
|
|
48
|
+
const result = defaultClassifyError(abortErr, {
|
|
49
|
+
...meta, timeoutSignal, timeoutMs: 1, userSignal: userController.signal,
|
|
50
|
+
})
|
|
51
|
+
expect(result?.kind).toBe('timeout')
|
|
52
|
+
resolve()
|
|
53
|
+
}, 1))
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('returns null for unknown errors', () => {
|
|
57
|
+
const result = defaultClassifyError(new Error('weird'), { ...meta })
|
|
58
|
+
expect(result).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('returns null for non-Error throws', () => {
|
|
62
|
+
expect(defaultClassifyError('string-thrown', { ...meta })).toBeNull()
|
|
63
|
+
expect(defaultClassifyError(42, { ...meta })).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ClientNetworkError,
|
|
3
|
+
ClientTimeoutError,
|
|
4
|
+
ClientAbortError,
|
|
5
|
+
} from './errors.js'
|
|
6
|
+
import type { ErrorClassifier } from './types.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default classifier — recognizes:
|
|
10
|
+
* - `TypeError` from fetch → `ClientNetworkError`
|
|
11
|
+
* - `DOMException` with `name: 'AbortError'` + timeout-signal-aborted → `ClientTimeoutError`
|
|
12
|
+
* - `DOMException` with `name: 'AbortError'` + user-signal-aborted → `ClientAbortError`
|
|
13
|
+
*
|
|
14
|
+
* Returns `null` for anything else. Timeout precedence: when both signals
|
|
15
|
+
* fired, classifies as `timeout`.
|
|
16
|
+
*/
|
|
17
|
+
export const defaultClassifyError: ErrorClassifier = (raw, ctx) => {
|
|
18
|
+
const meta = { procedureName: ctx.procedureName, scope: ctx.scope }
|
|
19
|
+
|
|
20
|
+
if (raw instanceof TypeError) {
|
|
21
|
+
return {
|
|
22
|
+
kind: 'network',
|
|
23
|
+
error: new ClientNetworkError({ ...meta, cause: raw, message: raw.message }),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (
|
|
28
|
+
raw instanceof DOMException &&
|
|
29
|
+
raw.name === 'AbortError'
|
|
30
|
+
) {
|
|
31
|
+
if (ctx.timeoutSignal?.aborted) {
|
|
32
|
+
return {
|
|
33
|
+
kind: 'timeout',
|
|
34
|
+
error: new ClientTimeoutError({
|
|
35
|
+
...meta,
|
|
36
|
+
timeoutMs: ctx.timeoutMs ?? 0,
|
|
37
|
+
cause: raw,
|
|
38
|
+
}),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (ctx.userSignal?.aborted) {
|
|
42
|
+
return {
|
|
43
|
+
kind: 'aborted',
|
|
44
|
+
error: new ClientAbortError({
|
|
45
|
+
...meta,
|
|
46
|
+
reason: ctx.userSignal.reason,
|
|
47
|
+
cause: raw,
|
|
48
|
+
}),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// AbortError without a tracked source — treat as user abort with no reason.
|
|
52
|
+
return {
|
|
53
|
+
kind: 'aborted',
|
|
54
|
+
error: new ClientAbortError({ ...meta, cause: raw }),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
@@ -9,7 +9,7 @@ import type { ErrorRegistry, ErrorResponseMeta } from './types.js'
|
|
|
9
9
|
* - `fromResponse` returns a non-Error value (defensive — registry entries
|
|
10
10
|
* are expected to return `Error` subclasses).
|
|
11
11
|
*
|
|
12
|
-
* Callers fall back to `
|
|
12
|
+
* Callers fall back to `ClientHttpError` when this returns `null`.
|
|
13
13
|
*/
|
|
14
14
|
export function dispatchTypedError(
|
|
15
15
|
registry: ErrorRegistry | undefined,
|
|
@@ -1,9 +1,40 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ClientHttpError,
|
|
4
|
+
ClientRequestError,
|
|
5
|
+
ClientPathParamError,
|
|
6
|
+
ClientStreamError,
|
|
7
|
+
ClientNetworkError,
|
|
8
|
+
ClientTimeoutError,
|
|
9
|
+
ClientAbortError,
|
|
10
|
+
ClientParseError,
|
|
11
|
+
} from './errors.js'
|
|
12
|
+
|
|
13
|
+
describe('ClientHttpError', () => {
|
|
14
|
+
it('exposes status, headers, body, procedureName, scope', () => {
|
|
15
|
+
const err = new ClientHttpError({
|
|
16
|
+
status: 500, headers: { 'x-trace': 'abc' }, body: { error: 'boom' },
|
|
17
|
+
procedureName: 'GetUser', scope: 'users',
|
|
18
|
+
})
|
|
19
|
+
expect(err.status).toBe(500)
|
|
20
|
+
expect(err.headers['x-trace']).toBe('abc')
|
|
21
|
+
expect(err.body).toEqual({ error: 'boom' })
|
|
22
|
+
expect(err.procedureName).toBe('GetUser')
|
|
23
|
+
expect(err.scope).toBe('users')
|
|
24
|
+
expect(err.name).toBe('ClientHttpError')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('accepts cause', () => {
|
|
28
|
+
const cause = new Error('underlying')
|
|
29
|
+
const err = new ClientHttpError({
|
|
30
|
+
status: 500, headers: {}, body: null,
|
|
31
|
+
procedureName: 'X', scope: 'y', cause,
|
|
32
|
+
})
|
|
33
|
+
expect(err.cause).toBe(cause)
|
|
34
|
+
})
|
|
3
35
|
|
|
4
|
-
describe('ClientRequestError', () => {
|
|
5
36
|
it('includes status, headers, and body', () => {
|
|
6
|
-
const err = new
|
|
37
|
+
const err = new ClientHttpError({
|
|
7
38
|
status: 401,
|
|
8
39
|
headers: { 'x-request-id': 'abc' },
|
|
9
40
|
body: { message: 'Unauthorized' },
|
|
@@ -11,7 +42,7 @@ describe('ClientRequestError', () => {
|
|
|
11
42
|
scope: 'users',
|
|
12
43
|
})
|
|
13
44
|
expect(err).toBeInstanceOf(Error)
|
|
14
|
-
expect(err.name).toBe('
|
|
45
|
+
expect(err.name).toBe('ClientHttpError')
|
|
15
46
|
expect(err.status).toBe(401)
|
|
16
47
|
expect(err.headers['x-request-id']).toBe('abc')
|
|
17
48
|
expect(err.body).toEqual({ message: 'Unauthorized' })
|
|
@@ -21,6 +52,23 @@ describe('ClientRequestError', () => {
|
|
|
21
52
|
})
|
|
22
53
|
})
|
|
23
54
|
|
|
55
|
+
describe('ClientRequestError deprecated alias', () => {
|
|
56
|
+
it('is identical to ClientHttpError', () => {
|
|
57
|
+
expect(ClientRequestError).toBe(ClientHttpError)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('instanceof check works against thrown ClientHttpError', () => {
|
|
61
|
+
// Consumers on the previous major used `catch (e) { if (e instanceof
|
|
62
|
+
// ClientRequestError) ... }`. The alias must keep that pattern working
|
|
63
|
+
// until the alias is removed in the next minor cycle's deprecation window.
|
|
64
|
+
const err = new ClientHttpError({
|
|
65
|
+
status: 500, headers: {}, body: null,
|
|
66
|
+
procedureName: 'X', scope: 'y',
|
|
67
|
+
})
|
|
68
|
+
expect(err instanceof ClientRequestError).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
24
72
|
describe('ClientPathParamError', () => {
|
|
25
73
|
it('reports missing param', () => {
|
|
26
74
|
const err = new ClientPathParamError('id', '/users/:id', 'GetUser')
|
|
@@ -29,6 +77,12 @@ describe('ClientPathParamError', () => {
|
|
|
29
77
|
expect(err.message).toContain('id')
|
|
30
78
|
expect(err.message).toContain('/users/:id')
|
|
31
79
|
})
|
|
80
|
+
|
|
81
|
+
it('accepts and stores cause', () => {
|
|
82
|
+
const cause = new TypeError('underlying')
|
|
83
|
+
const err = new ClientPathParamError('id', '/u/:id', 'GetUser', cause)
|
|
84
|
+
expect(err.cause).toBe(cause)
|
|
85
|
+
})
|
|
32
86
|
})
|
|
33
87
|
|
|
34
88
|
describe('ClientStreamError', () => {
|
|
@@ -40,4 +94,54 @@ describe('ClientStreamError', () => {
|
|
|
40
94
|
expect(err.scope).toBe('events')
|
|
41
95
|
expect(err.message).toBe('stream interrupted')
|
|
42
96
|
})
|
|
97
|
+
|
|
98
|
+
it('accepts and stores cause', () => {
|
|
99
|
+
const cause = new Error('underlying')
|
|
100
|
+
const err = new ClientStreamError('boom', 'StreamUsers', 'users', cause)
|
|
101
|
+
expect(err.cause).toBe(cause)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('ClientNetworkError', () => {
|
|
106
|
+
it('carries procedureName, scope, cause', () => {
|
|
107
|
+
const cause = new TypeError('fetch failed')
|
|
108
|
+
const err = new ClientNetworkError({
|
|
109
|
+
procedureName: 'X', scope: 'y', cause,
|
|
110
|
+
})
|
|
111
|
+
expect(err.name).toBe('ClientNetworkError')
|
|
112
|
+
expect(err.procedureName).toBe('X')
|
|
113
|
+
expect(err.scope).toBe('y')
|
|
114
|
+
expect(err.cause).toBe(cause)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('ClientTimeoutError', () => {
|
|
119
|
+
it('carries timeoutMs', () => {
|
|
120
|
+
const err = new ClientTimeoutError({
|
|
121
|
+
procedureName: 'X', scope: 'y', timeoutMs: 5000,
|
|
122
|
+
})
|
|
123
|
+
expect(err.name).toBe('ClientTimeoutError')
|
|
124
|
+
expect(err.timeoutMs).toBe(5000)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('ClientAbortError', () => {
|
|
129
|
+
it('carries reason from abort signal', () => {
|
|
130
|
+
const err = new ClientAbortError({
|
|
131
|
+
procedureName: 'X', scope: 'y', reason: 'user-cancelled',
|
|
132
|
+
})
|
|
133
|
+
expect(err.name).toBe('ClientAbortError')
|
|
134
|
+
expect(err.reason).toBe('user-cancelled')
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('ClientParseError', () => {
|
|
139
|
+
it('carries procedureName, scope, cause', () => {
|
|
140
|
+
const cause = new SyntaxError('Unexpected token')
|
|
141
|
+
const err = new ClientParseError({
|
|
142
|
+
procedureName: 'X', scope: 'y', cause,
|
|
143
|
+
})
|
|
144
|
+
expect(err.name).toBe('ClientParseError')
|
|
145
|
+
expect(err.cause).toBe(cause)
|
|
146
|
+
})
|
|
43
147
|
})
|
package/src/client/errors.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export class
|
|
2
|
-
readonly name = '
|
|
1
|
+
export class ClientHttpError extends Error {
|
|
2
|
+
readonly name = 'ClientHttpError'
|
|
3
3
|
readonly status: number
|
|
4
4
|
readonly headers: Record<string, string>
|
|
5
5
|
readonly body: unknown
|
|
@@ -12,8 +12,12 @@ export class ClientRequestError extends Error {
|
|
|
12
12
|
body: unknown
|
|
13
13
|
procedureName: string
|
|
14
14
|
scope: string
|
|
15
|
+
cause?: unknown
|
|
15
16
|
}) {
|
|
16
|
-
super(
|
|
17
|
+
super(
|
|
18
|
+
`${opts.procedureName} (${opts.scope}) failed with status ${opts.status}`,
|
|
19
|
+
{ cause: opts.cause }
|
|
20
|
+
)
|
|
17
21
|
this.status = opts.status
|
|
18
22
|
this.headers = opts.headers
|
|
19
23
|
this.body = opts.body
|
|
@@ -22,11 +26,18 @@ export class ClientRequestError extends Error {
|
|
|
22
26
|
}
|
|
23
27
|
}
|
|
24
28
|
|
|
29
|
+
/** @deprecated Renamed to `ClientHttpError`. The alias is retained for one minor release after the 7.0.0 major bump and will be removed in a subsequent minor (e.g. 7.1.0). Migrate to `ClientHttpError` now. */
|
|
30
|
+
// eslint-disable-next-line no-redeclare
|
|
31
|
+
export const ClientRequestError = ClientHttpError
|
|
32
|
+
/** @deprecated Renamed to `ClientHttpError`. The alias is retained for one minor release after the 7.0.0 major bump and will be removed in a subsequent minor (e.g. 7.1.0). Migrate to `ClientHttpError` now. */
|
|
33
|
+
// eslint-disable-next-line no-redeclare
|
|
34
|
+
export type ClientRequestError = ClientHttpError
|
|
35
|
+
|
|
25
36
|
export class ClientPathParamError extends Error {
|
|
26
37
|
readonly name = 'ClientPathParamError'
|
|
27
38
|
|
|
28
|
-
constructor(param: string, path: string, procedureName: string) {
|
|
29
|
-
super(`Missing path parameter "${param}" in "${path}" for procedure ${procedureName}
|
|
39
|
+
constructor(param: string, path: string, procedureName: string, cause?: unknown) {
|
|
40
|
+
super(`Missing path parameter "${param}" in "${path}" for procedure ${procedureName}`, { cause })
|
|
30
41
|
}
|
|
31
42
|
}
|
|
32
43
|
|
|
@@ -35,9 +46,61 @@ export class ClientStreamError extends Error {
|
|
|
35
46
|
readonly procedureName: string
|
|
36
47
|
readonly scope: string
|
|
37
48
|
|
|
38
|
-
constructor(message: string, procedureName: string, scope: string) {
|
|
39
|
-
super(message)
|
|
49
|
+
constructor(message: string, procedureName: string, scope: string, cause?: unknown) {
|
|
50
|
+
super(message, { cause })
|
|
40
51
|
this.procedureName = procedureName
|
|
41
52
|
this.scope = scope
|
|
42
53
|
}
|
|
43
54
|
}
|
|
55
|
+
|
|
56
|
+
export class ClientNetworkError extends Error {
|
|
57
|
+
readonly name = 'ClientNetworkError'
|
|
58
|
+
readonly procedureName: string
|
|
59
|
+
readonly scope: string
|
|
60
|
+
|
|
61
|
+
constructor(opts: { procedureName: string; scope: string; cause?: unknown; message?: string }) {
|
|
62
|
+
super(opts.message ?? `${opts.procedureName} (${opts.scope}) failed: network error`, { cause: opts.cause })
|
|
63
|
+
this.procedureName = opts.procedureName
|
|
64
|
+
this.scope = opts.scope
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class ClientTimeoutError extends Error {
|
|
69
|
+
readonly name = 'ClientTimeoutError'
|
|
70
|
+
readonly procedureName: string
|
|
71
|
+
readonly scope: string
|
|
72
|
+
readonly timeoutMs: number
|
|
73
|
+
|
|
74
|
+
constructor(opts: { procedureName: string; scope: string; timeoutMs: number; cause?: unknown }) {
|
|
75
|
+
super(`${opts.procedureName} (${opts.scope}) timed out after ${opts.timeoutMs}ms`, { cause: opts.cause })
|
|
76
|
+
this.procedureName = opts.procedureName
|
|
77
|
+
this.scope = opts.scope
|
|
78
|
+
this.timeoutMs = opts.timeoutMs
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class ClientAbortError extends Error {
|
|
83
|
+
readonly name = 'ClientAbortError'
|
|
84
|
+
readonly procedureName: string
|
|
85
|
+
readonly scope: string
|
|
86
|
+
readonly reason: unknown
|
|
87
|
+
|
|
88
|
+
constructor(opts: { procedureName: string; scope: string; reason?: unknown; cause?: unknown }) {
|
|
89
|
+
super(`${opts.procedureName} (${opts.scope}) aborted`, { cause: opts.cause })
|
|
90
|
+
this.procedureName = opts.procedureName
|
|
91
|
+
this.scope = opts.scope
|
|
92
|
+
this.reason = opts.reason
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class ClientParseError extends Error {
|
|
97
|
+
readonly name = 'ClientParseError'
|
|
98
|
+
readonly procedureName: string
|
|
99
|
+
readonly scope: string
|
|
100
|
+
|
|
101
|
+
constructor(opts: { procedureName: string; scope: string; cause?: unknown; message?: string }) {
|
|
102
|
+
super(opts.message ?? `${opts.procedureName} (${opts.scope}) response could not be parsed`, { cause: opts.cause })
|
|
103
|
+
this.procedureName = opts.procedureName
|
|
104
|
+
this.scope = opts.scope
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -338,3 +338,18 @@ describe('createFetchAdapter — stream()', () => {
|
|
|
338
338
|
expect(items).toEqual([{ data: { ok: true }, event: 'message', id: undefined }])
|
|
339
339
|
})
|
|
340
340
|
})
|
|
341
|
+
|
|
342
|
+
// ── classifyError config ──────────────────────────────
|
|
343
|
+
|
|
344
|
+
describe('createFetchAdapter classifyError', () => {
|
|
345
|
+
it('passes through to adapter.classifyError', () => {
|
|
346
|
+
const customClassifier = vi.fn()
|
|
347
|
+
const adapter = createFetchAdapter({ classifyError: customClassifier })
|
|
348
|
+
expect(adapter.classifyError).toBe(customClassifier)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('does not set classifyError when not provided', () => {
|
|
352
|
+
const adapter = createFetchAdapter()
|
|
353
|
+
expect(adapter.classifyError).toBeUndefined()
|
|
354
|
+
})
|
|
355
|
+
})
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type { ClientAdapter, AdapterRequest, AdapterResponse, AdapterStreamResponse } from './types.js'
|
|
1
|
+
import type { ClientAdapter, AdapterRequest, AdapterResponse, AdapterStreamResponse, ErrorClassifier } from './types.js'
|
|
2
2
|
|
|
3
3
|
// ── Config ────────────────────────────────────────────────
|
|
4
4
|
|
|
5
5
|
export interface FetchAdapterConfig {
|
|
6
6
|
headers?: Record<string, string>
|
|
7
|
+
classifyError?: ErrorClassifier
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
// ── SSE parser ────────────────────────────────────────────
|
|
@@ -180,7 +181,7 @@ export function createFetchAdapter(config?: FetchAdapterConfig): ClientAdapter {
|
|
|
180
181
|
|
|
181
182
|
// Non-2xx responses on a stream endpoint are JSON, not SSE. Parse the
|
|
182
183
|
// body eagerly and surface it via errorBody so the client can dispatch
|
|
183
|
-
// a typed error (or fall back to
|
|
184
|
+
// a typed error (or fall back to ClientHttpError with a real body).
|
|
184
185
|
if (response.status < 200 || response.status >= 300) {
|
|
185
186
|
const errorBody = await parseResponseBody(response)
|
|
186
187
|
return { status: response.status, headers, body: emptyBody, errorBody }
|
|
@@ -193,5 +194,7 @@ export function createFetchAdapter(config?: FetchAdapterConfig): ClientAdapter {
|
|
|
193
194
|
const body = parseSseStream(response.body as ReadableStream<Uint8Array>)
|
|
194
195
|
return { status: response.status, headers, body }
|
|
195
196
|
},
|
|
197
|
+
|
|
198
|
+
classifyError: config?.classifyError,
|
|
196
199
|
}
|
|
197
200
|
}
|