@empyria/restate 0.1.20 → 0.1.22
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/lib/Admin.js +76 -16
- package/lib/Validation.js +28 -5
- package/package.json +5 -5
- package/test/Admin.test.js +60 -0
- package/test/Validation.test.js +36 -6
package/lib/Admin.js
CHANGED
|
@@ -212,15 +212,54 @@ export async function listHandlers({ restateAdminURL, name }) {
|
|
|
212
212
|
* @property {object} payload
|
|
213
213
|
* @typedef {BaseMessage & {key: string}} WorkflowMessage Message targeting a Restate
|
|
214
214
|
* workflow — `key` is required to address the workflow instance.
|
|
215
|
-
* @typedef {BaseMessage & {key?: string, message: string}} ServiceMessage
|
|
216
|
-
* targeting a Restate service or virtual object handler. `key` is the Virtual
|
|
217
|
-
* Object key (omit for plain services); `message` is the handler name.
|
|
215
|
+
* @typedef {BaseMessage & {key?: string, message: string, idempotencyKey?: string}} ServiceMessage
|
|
216
|
+
* Message targeting a Restate service or virtual object handler. `key` is the Virtual
|
|
217
|
+
* Object key (omit for plain services); `message` is the handler name. `idempotencyKey`,
|
|
218
|
+
* when set, is sent as Restate's `idempotency-key` header: resending the same message
|
|
219
|
+
* with the same key (e.g. after a network timeout) attaches to the original invocation
|
|
220
|
+
* instead of running the handler twice.
|
|
218
221
|
* @typedef {Object} InvocationSubmission Response returned by Restate when an
|
|
219
222
|
* invocation is enqueued asynchronously.
|
|
220
223
|
* @property {string} invocationId
|
|
221
224
|
* @property {'Accepted'|'PreviouslyAccepted'} status
|
|
222
225
|
*/
|
|
223
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Separator {@link childKey} joins key segments with. Not `/`: keys are interpolated
|
|
229
|
+
* straight into ingress URL paths (see {@link submitWorkflow}), where a `/` would split
|
|
230
|
+
* the key into extra path segments.
|
|
231
|
+
*/
|
|
232
|
+
export const CHILD_KEY_SEPARATOR = ':'
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Derives a sub-workflow's key deterministically from its parent's, so a replayed parent
|
|
236
|
+
* addresses the very same child instead of starting a new one. Never use a random ID
|
|
237
|
+
* for a child: a workflow's `run` executes once per key, and the key is what makes the
|
|
238
|
+
* call idempotent across replays.
|
|
239
|
+
*
|
|
240
|
+
* childKey('order-42', 'approval') // 'order-42:approval'
|
|
241
|
+
* childKey('order-42', 'shipment', 3) // 'order-42:shipment:3'
|
|
242
|
+
* @param {string} parentKey key of the calling workflow (`ctx.key`)
|
|
243
|
+
* @param {string} nodePath stable name of the calling step within the parent's flow
|
|
244
|
+
* @param {number|string} [iteration] loop index, when the step runs more than once
|
|
245
|
+
* @returns {string}
|
|
246
|
+
* @throws {TypeError} If a segment is empty or contains {@link CHILD_KEY_SEPARATOR} or `/`.
|
|
247
|
+
*/
|
|
248
|
+
export function childKey(parentKey, nodePath, iteration) {
|
|
249
|
+
const segments = iteration === undefined ? [nodePath] : [nodePath, String(iteration)]
|
|
250
|
+
for (const segment of segments) {
|
|
251
|
+
if (typeof segment !== 'string' || segment === '' || /[:/]/.test(segment)) {
|
|
252
|
+
throw new TypeError(
|
|
253
|
+
`Invalid child key segment '${segment}': must be a non-empty string without '${CHILD_KEY_SEPARATOR}' or '/'`,
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (typeof parentKey !== 'string' || parentKey === '') {
|
|
258
|
+
throw new TypeError('childKey requires a non-empty parentKey')
|
|
259
|
+
}
|
|
260
|
+
return [parentKey, ...segments].join(CHILD_KEY_SEPARATOR)
|
|
261
|
+
}
|
|
262
|
+
|
|
224
263
|
/**
|
|
225
264
|
* Starts a sub-workflow and returns a handle to the invocation. Submitting against a
|
|
226
265
|
* `workflowKey` that already has a running or completed workflow is a safe no-op on
|
|
@@ -280,7 +319,10 @@ export function callSubWorkflow(ctx, workflowName, workflowKey, payload) {
|
|
|
280
319
|
* @param {Object} options
|
|
281
320
|
* @param {string} options.restateURL - Restate endpoint URL
|
|
282
321
|
* @param {string} options.name - Workflow name
|
|
283
|
-
* @param {string} [options.key] - Unique workflow instance key
|
|
322
|
+
* @param {string} [options.key] - Unique workflow instance key. This is also the workflow's
|
|
323
|
+
* idempotency key: resubmitting the same key is `PreviouslyAccepted`, not a second run.
|
|
324
|
+
* Omitting it generates a random key, so a retried submission starts a duplicate run —
|
|
325
|
+
* pass a business key (e.g. the order ID) whenever the caller may retry.
|
|
284
326
|
* @param {TPayload} options.payload - Request payload
|
|
285
327
|
* @returns {Promise<InvocationSubmission>}
|
|
286
328
|
*/
|
|
@@ -366,17 +408,28 @@ export async function pollInvocation({ restateURL, invocationId, intervalMs = 25
|
|
|
366
408
|
}
|
|
367
409
|
}
|
|
368
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Request headers for an ingress call, adding Restate's `idempotency-key` when given.
|
|
413
|
+
* @param {string} [idempotencyKey]
|
|
414
|
+
* @returns {Record<string, string>}
|
|
415
|
+
*/
|
|
416
|
+
function ingressHeaders(idempotencyKey) {
|
|
417
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
418
|
+
if (idempotencyKey) headers['idempotency-key'] = idempotencyKey
|
|
419
|
+
return headers
|
|
420
|
+
}
|
|
421
|
+
|
|
369
422
|
/**
|
|
370
423
|
* Sends a message to a Restate service with configurable retry policy.
|
|
371
424
|
* @template TResult
|
|
372
425
|
* @param {ServiceMessage} params
|
|
373
426
|
* @returns {Promise<TResult>}
|
|
374
427
|
*/
|
|
375
|
-
export async function sendMessage({ restateURL, name, message, key, payload }) {
|
|
428
|
+
export async function sendMessage({ restateURL, name, message, key, payload, idempotencyKey }) {
|
|
376
429
|
const path = key ? `${name}/${key}/${message}` : `${name}/${message}`
|
|
377
430
|
const response = await fetch(`${restateURL}/${path}`, {
|
|
378
431
|
method: 'POST',
|
|
379
|
-
headers:
|
|
432
|
+
headers: ingressHeaders(idempotencyKey),
|
|
380
433
|
body: JSON.stringify(payload),
|
|
381
434
|
})
|
|
382
435
|
|
|
@@ -395,11 +448,18 @@ export async function sendMessage({ restateURL, name, message, key, payload }) {
|
|
|
395
448
|
* @param {ServiceMessage} params
|
|
396
449
|
* @returns {Promise<InvocationSubmission>}
|
|
397
450
|
*/
|
|
398
|
-
export async function sendMessageAsync({
|
|
451
|
+
export async function sendMessageAsync({
|
|
452
|
+
restateURL,
|
|
453
|
+
name,
|
|
454
|
+
message,
|
|
455
|
+
key,
|
|
456
|
+
payload,
|
|
457
|
+
idempotencyKey,
|
|
458
|
+
}) {
|
|
399
459
|
const path = key ? `${name}/${key}/${message}` : `${name}/${message}`
|
|
400
460
|
const response = await fetch(`${restateURL}/${path}/send`, {
|
|
401
461
|
method: 'POST',
|
|
402
|
-
headers:
|
|
462
|
+
headers: ingressHeaders(idempotencyKey),
|
|
403
463
|
body: JSON.stringify(payload),
|
|
404
464
|
})
|
|
405
465
|
|
|
@@ -429,10 +489,10 @@ export async function sendMessageAsync({ restateURL, name, message, key, payload
|
|
|
429
489
|
* @returns {(message: ServiceMessage) => Promise<TResult>}
|
|
430
490
|
*/
|
|
431
491
|
export function sendMessageWithDiscovery({ restateAdminURL }) {
|
|
432
|
-
return async ({ restateURL, name, message, key, payload }) => {
|
|
492
|
+
return async ({ restateURL, name, message, key, payload, idempotencyKey }) => {
|
|
433
493
|
await checkServiceHandler(restateAdminURL, name, message)
|
|
434
494
|
|
|
435
|
-
return sendMessage({ restateURL, name, message, key, payload })
|
|
495
|
+
return sendMessage({ restateURL, name, message, key, payload, idempotencyKey })
|
|
436
496
|
}
|
|
437
497
|
}
|
|
438
498
|
|
|
@@ -443,10 +503,10 @@ export function sendMessageWithDiscovery({ restateAdminURL }) {
|
|
|
443
503
|
* @returns {(message: ServiceMessage) => Promise<InvocationSubmission>}
|
|
444
504
|
*/
|
|
445
505
|
export function sendMessageAsyncWithDiscovery({ restateAdminURL }) {
|
|
446
|
-
return async ({ restateURL, name, message, key, payload }) => {
|
|
506
|
+
return async ({ restateURL, name, message, key, payload, idempotencyKey }) => {
|
|
447
507
|
await checkServiceHandler(restateAdminURL, name, message)
|
|
448
508
|
|
|
449
|
-
return sendMessageAsync({ restateURL, name, message, key, payload })
|
|
509
|
+
return sendMessageAsync({ restateURL, name, message, key, payload, idempotencyKey })
|
|
450
510
|
}
|
|
451
511
|
}
|
|
452
512
|
|
|
@@ -772,10 +832,10 @@ export function createRestateAdmin({ restateAdminURL, restateURL }) {
|
|
|
772
832
|
pollInvocation: ({ invocationId, intervalMs }) =>
|
|
773
833
|
pollInvocation({ restateURL, invocationId, intervalMs }),
|
|
774
834
|
|
|
775
|
-
sendMessage: ({ name, message, key, payload }) =>
|
|
776
|
-
sendMessage({ restateURL, name, message, key, payload }),
|
|
777
|
-
sendMessageAsync: ({ name, message, key, payload }) =>
|
|
778
|
-
sendMessageAsync({ restateURL, name, message, key, payload }),
|
|
835
|
+
sendMessage: ({ name, message, key, payload, idempotencyKey }) =>
|
|
836
|
+
sendMessage({ restateURL, name, message, key, payload, idempotencyKey }),
|
|
837
|
+
sendMessageAsync: ({ name, message, key, payload, idempotencyKey }) =>
|
|
838
|
+
sendMessageAsync({ restateURL, name, message, key, payload, idempotencyKey }),
|
|
779
839
|
sendMessageWithDiscovery: () => sendMessageWithDiscovery({ restateAdminURL }),
|
|
780
840
|
sendMessageAsyncWithDiscovery: () => sendMessageAsyncWithDiscovery({ restateAdminURL }),
|
|
781
841
|
}
|
package/lib/Validation.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TerminalError } from '@restatedev/restate-sdk'
|
|
1
2
|
import { createValidator } from '@empyria/common'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -15,6 +16,14 @@ import { createValidator } from '@empyria/common'
|
|
|
15
16
|
* that doesn't match the declared output type, the error is caught before
|
|
16
17
|
* Restate records the result.
|
|
17
18
|
*
|
|
19
|
+
* Validation failures are thrown as `TerminalError`, never as the plain
|
|
20
|
+
* `ValidationError` `@empyria/common` raises: Restate retries any non-terminal
|
|
21
|
+
* error indefinitely, and a payload that failed validation once fails it on
|
|
22
|
+
* every retry. Input failures carry errorCode 400 (the caller's fault), output
|
|
23
|
+
* failures 500 (the handler's fault) — Restate propagates the code to an
|
|
24
|
+
* ingress caller as the HTTP status. Errors thrown by `handler` itself are left
|
|
25
|
+
* untouched, so transient failures there are still retried.
|
|
26
|
+
*
|
|
18
27
|
* Usage:
|
|
19
28
|
* handlers: {
|
|
20
29
|
* run: withValidation(InputSchema, OutputSchema, async (ctx, input) => { ... })
|
|
@@ -25,15 +34,29 @@ export function withValidation(inputSchema, outputSchema, handler) {
|
|
|
25
34
|
const validateOutput = createValidator(outputSchema)
|
|
26
35
|
|
|
27
36
|
return async (ctx, input) => {
|
|
28
|
-
|
|
29
|
-
const validatedInput = validateInput(input)
|
|
37
|
+
const validatedInput = asTerminal(validateInput, input, 'input', 400)
|
|
30
38
|
|
|
31
39
|
// Execute the actual workflow logic.
|
|
32
40
|
const result = await handler(ctx, validatedInput)
|
|
33
41
|
|
|
34
|
-
|
|
35
|
-
|
|
42
|
+
return asTerminal(validateOutput, result, 'output', 500)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
36
45
|
|
|
37
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Runs `validator(value)`, rethrowing any failure as a `TerminalError`.
|
|
48
|
+
* @param {(value: *) => *} validator
|
|
49
|
+
* @param {*} value
|
|
50
|
+
* @param {'input'|'output'} side
|
|
51
|
+
* @param {number} errorCode
|
|
52
|
+
*/
|
|
53
|
+
function asTerminal(validator, value, side, errorCode) {
|
|
54
|
+
try {
|
|
55
|
+
return validator(value)
|
|
56
|
+
} catch (e) {
|
|
57
|
+
throw new TerminalError(e.message, {
|
|
58
|
+
errorCode,
|
|
59
|
+
metadata: { validation: side, errorName: String(e.errorName ?? e.name) },
|
|
60
|
+
})
|
|
38
61
|
}
|
|
39
62
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@empyria/restate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
4
4
|
"description": "Restate.dev helpers for the Empyria nanoservice framework",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Imre Fazekas <imre.fazekas@icloud.com>",
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"test:watch": "bun test --watch"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@empyria/classification": "0.1.
|
|
28
|
-
"@empyria/common": "0.3.
|
|
27
|
+
"@empyria/classification": "0.1.9",
|
|
28
|
+
"@empyria/common": "0.3.7",
|
|
29
29
|
"@restatedev/pubsub": "0.3.2",
|
|
30
30
|
"@restatedev/pubsub-client": "0.3.2",
|
|
31
31
|
"@restatedev/restate-sdk": "1.17.2",
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@restatedev/restate-server": "1.7.12",
|
|
38
|
-
"oxfmt": "0.
|
|
39
|
-
"oxlint": "1.
|
|
38
|
+
"oxfmt": "0.71.0",
|
|
39
|
+
"oxlint": "1.86.0"
|
|
40
40
|
},
|
|
41
41
|
"engines": {
|
|
42
42
|
"bun": ">=1.4.2",
|
package/test/Admin.test.js
CHANGED
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
checkServiceHandler,
|
|
4
4
|
startSubWorkflow,
|
|
5
5
|
callSubWorkflow,
|
|
6
|
+
childKey,
|
|
7
|
+
CHILD_KEY_SEPARATOR,
|
|
6
8
|
listServices,
|
|
7
9
|
queryRestate,
|
|
8
10
|
deleteDeployment,
|
|
@@ -113,6 +115,32 @@ describe('checkServiceHandler', () => {
|
|
|
113
115
|
})
|
|
114
116
|
})
|
|
115
117
|
|
|
118
|
+
describe('childKey', () => {
|
|
119
|
+
test('joins parent key and node path', () => {
|
|
120
|
+
expect(childKey('order-42', 'approval')).toBe('order-42:approval')
|
|
121
|
+
expect(CHILD_KEY_SEPARATOR).toBe(':')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('appends the iteration when given, including 0', () => {
|
|
125
|
+
expect(childKey('order-42', 'shipment', 3)).toBe('order-42:shipment:3')
|
|
126
|
+
expect(childKey('order-42', 'shipment', 0)).toBe('order-42:shipment:0')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('nests: a grandchild key extends the child key', () => {
|
|
130
|
+
expect(childKey(childKey('order-42', 'approval'), 'review', 1)).toBe(
|
|
131
|
+
'order-42:approval:review:1',
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('rejects empty or separator-containing segments and an empty parent', () => {
|
|
136
|
+
expect(() => childKey('order-42', '')).toThrow(TypeError)
|
|
137
|
+
expect(() => childKey('order-42', 'a:b')).toThrow(TypeError)
|
|
138
|
+
expect(() => childKey('order-42', 'a/b')).toThrow(TypeError)
|
|
139
|
+
expect(() => childKey('order-42', 'step', '')).toThrow(TypeError)
|
|
140
|
+
expect(() => childKey('', 'step')).toThrow(TypeError)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
116
144
|
describe('startSubWorkflow / callSubWorkflow', () => {
|
|
117
145
|
test("startSubWorkflow sends via ctx.genericSend to the workflow's run handler", () => {
|
|
118
146
|
let sentArgs
|
|
@@ -389,6 +417,23 @@ describe('sendMessage / sendMessageAsync', () => {
|
|
|
389
417
|
).rejects.toThrow()
|
|
390
418
|
})
|
|
391
419
|
|
|
420
|
+
test('sends the idempotency-key header only when idempotencyKey is given', async () => {
|
|
421
|
+
const seen = []
|
|
422
|
+
globalThis.fetch = mock(async (url, init) => {
|
|
423
|
+
seen.push(init.headers)
|
|
424
|
+
return String(url).endsWith('/send')
|
|
425
|
+
? jsonResponse({ invocationId: 'inv-3', status: 'PreviouslyAccepted' })
|
|
426
|
+
: { ok: true, text: async () => '' }
|
|
427
|
+
})
|
|
428
|
+
const base = { restateURL: 'http://ingress', name: 'Svc', message: 'do', payload: {} }
|
|
429
|
+
await sendMessage({ ...base, idempotencyKey: 'req-1' })
|
|
430
|
+
await sendMessageAsync({ ...base, idempotencyKey: 'req-2' })
|
|
431
|
+
await sendMessage(base)
|
|
432
|
+
expect(seen[0]['idempotency-key']).toBe('req-1')
|
|
433
|
+
expect(seen[1]['idempotency-key']).toBe('req-2')
|
|
434
|
+
expect(seen[2]).not.toHaveProperty('idempotency-key')
|
|
435
|
+
})
|
|
436
|
+
|
|
392
437
|
test('sendMessageWithDiscovery / sendMessageAsyncWithDiscovery validate first', async () => {
|
|
393
438
|
globalThis.fetch = mock(async (url) => {
|
|
394
439
|
if (String(url).endsWith('/services')) return jsonResponse(servicesPayload)
|
|
@@ -522,4 +567,19 @@ describe('createRestateAdmin', () => {
|
|
|
522
567
|
})
|
|
523
568
|
expect(await admin.query('SELECT status FROM sys_invocation')).toEqual(rows)
|
|
524
569
|
})
|
|
570
|
+
test('sendMessage/sendMessageAsync forward idempotencyKey', async () => {
|
|
571
|
+
const seen = []
|
|
572
|
+
globalThis.fetch = mock(async (url, init) => {
|
|
573
|
+
seen.push(init.headers['idempotency-key'])
|
|
574
|
+
return jsonResponse({ invocationId: 'inv-4', status: 'Accepted' })
|
|
575
|
+
})
|
|
576
|
+
const admin = createRestateAdmin({
|
|
577
|
+
restateAdminURL: 'http://admin',
|
|
578
|
+
restateURL: 'http://ingress',
|
|
579
|
+
})
|
|
580
|
+
const msg = { name: 'Svc', message: 'do', payload: {} }
|
|
581
|
+
await admin.sendMessage({ ...msg, idempotencyKey: 'a' })
|
|
582
|
+
await admin.sendMessageAsync({ ...msg, idempotencyKey: 'b' })
|
|
583
|
+
expect(seen).toEqual(['a', 'b'])
|
|
584
|
+
})
|
|
525
585
|
})
|
package/test/Validation.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { TerminalError } from '@restatedev/restate-sdk'
|
|
2
3
|
import { withValidation } from '../lib/Validation.js'
|
|
3
|
-
import { EmpyriaError } from '@empyria/common'
|
|
4
4
|
|
|
5
5
|
const inputSchema = {
|
|
6
6
|
type: 'object',
|
|
@@ -13,6 +13,15 @@ const outputSchema = {
|
|
|
13
13
|
required: ['greeting'],
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
const rejection = async (promise) => {
|
|
17
|
+
try {
|
|
18
|
+
await promise
|
|
19
|
+
} catch (e) {
|
|
20
|
+
return e
|
|
21
|
+
}
|
|
22
|
+
throw new Error('expected a rejection')
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
describe('withValidation', () => {
|
|
17
26
|
test('validates input and output, and returns the validated output', async () => {
|
|
18
27
|
const handler = withValidation(inputSchema, outputSchema, async (ctx, input) => ({
|
|
@@ -22,13 +31,34 @@ describe('withValidation', () => {
|
|
|
22
31
|
expect(result).toEqual({ greeting: 'Hello, Bob' })
|
|
23
32
|
})
|
|
24
33
|
|
|
25
|
-
test('throws when the input does not match the schema', async () => {
|
|
26
|
-
|
|
27
|
-
|
|
34
|
+
test('throws a 400 TerminalError when the input does not match the schema', async () => {
|
|
35
|
+
let called = false
|
|
36
|
+
const handler = withValidation(inputSchema, outputSchema, async () => {
|
|
37
|
+
called = true
|
|
38
|
+
return { greeting: 'hi' }
|
|
39
|
+
})
|
|
40
|
+
const e = await rejection(handler({}, {}))
|
|
41
|
+
expect(e).toBeInstanceOf(TerminalError)
|
|
42
|
+
expect(e.code).toBe(400)
|
|
43
|
+
expect(e.metadata.validation).toBe('input')
|
|
44
|
+
expect(called).toBe(false)
|
|
28
45
|
})
|
|
29
46
|
|
|
30
|
-
test('throws when the handler output does not match the schema', async () => {
|
|
47
|
+
test('throws a 500 TerminalError when the handler output does not match the schema', async () => {
|
|
31
48
|
const handler = withValidation(inputSchema, outputSchema, async () => ({ wrong: true }))
|
|
32
|
-
await
|
|
49
|
+
const e = await rejection(handler({}, { name: 'Bob' }))
|
|
50
|
+
expect(e).toBeInstanceOf(TerminalError)
|
|
51
|
+
expect(e.code).toBe(500)
|
|
52
|
+
expect(e.metadata.validation).toBe('output')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('leaves errors thrown by the handler itself retryable', async () => {
|
|
56
|
+
const boom = new Error('transient')
|
|
57
|
+
const handler = withValidation(inputSchema, outputSchema, async () => {
|
|
58
|
+
throw boom
|
|
59
|
+
})
|
|
60
|
+
const e = await rejection(handler({}, { name: 'Bob' }))
|
|
61
|
+
expect(e).toBe(boom)
|
|
62
|
+
expect(e).not.toBeInstanceOf(TerminalError)
|
|
33
63
|
})
|
|
34
64
|
})
|