Skip to content

Commit c91bbc3

Browse files
authored
feat: anyware hook retries (#904)
1 parent 1b5e7fc commit c91bbc3

File tree

10 files changed

+618
-254
lines changed

10 files changed

+618
-254
lines changed

src/layers/5_client/client.extend.test.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
/* eslint-disable */
2-
import { ExecutionResult } from 'graphql'
32
import { describe, expect } from 'vitest'
43
import { db } from '../../../tests/_/db.js'
54
import { createResponse, test } from '../../../tests/_/helpers.js'
65
import { Graffle } from '../../../tests/_/schema/generated/__.js'
7-
import { GraphQLExecutionResult } from '../../legacy/lib/graphql.js'
6+
import { oops } from '../../lib/anyware/specHelpers.js'
87

98
const client = Graffle.create({ schema: 'https://foo', returnMode: 'dataAndErrors' })
109
const headers = { 'x-foo': 'bar' }
@@ -38,3 +37,26 @@ describe(`entrypoint request`, () => {
3837
expect(await client2.query.id()).toEqual(db.id)
3938
})
4039
})
40+
41+
test('can retry failed request', async ({ fetch }) => {
42+
fetch
43+
.mockImplementationOnce(async () => {
44+
throw oops
45+
})
46+
.mockImplementationOnce(async () => {
47+
throw oops
48+
})
49+
.mockImplementationOnce(async () => {
50+
return createResponse({ data: { id: db.id } })
51+
})
52+
const client2 = client.retry(async ({ exchange }) => {
53+
let result = await exchange()
54+
while (result instanceof Error) {
55+
result = await exchange()
56+
}
57+
return result
58+
})
59+
const result = await client2.query.id()
60+
expect(result).toEqual(db.id)
61+
expect(fetch.mock.calls.length).toEqual(3)
62+
})

src/layers/5_client/client.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export type SelectionSetOrIndicator = 0 | 1 | boolean | object
3939
export type SelectionSetOrArgs = object
4040

4141
export interface Context {
42+
retry: undefined | Anyware.Extension2<Core.Core, { retrying: true }>
4243
extensions: Anyware.Extension2<Core.Core>[]
4344
config: Config
4445
}
@@ -65,6 +66,7 @@ export type Client<$Index extends Schema.Index | null, $Config extends Config> =
6566
)
6667
& {
6768
extend: (extension: Anyware.Extension2<Core.Core>) => Client<$Index, $Config>
69+
retry: (extension: Anyware.Extension2<Core.Core, { retrying: true }>) => Client<$Index, $Config>
6870
}
6971

7072
export type ClientTyped<$Index extends Schema.Index, $Config extends Config> =
@@ -147,9 +149,10 @@ type Create = <
147149

148150
export const create: Create = (
149151
input_,
150-
) => createInternal(input_, { extensions: [] })
152+
) => createInternal(input_, { extensions: [], retry: undefined })
151153

152154
interface CreateState {
155+
retry?: Anyware.Extension2<Core.Core, { retrying: true }>
153156
extensions: Anyware.Extension2<Core.Core>[]
154157
}
155158

@@ -251,6 +254,7 @@ export const createInternal = (
251254
}
252255

253256
const context: Context = {
257+
retry: state.retry,
254258
extensions: state.extensions,
255259
config: {
256260
returnMode,
@@ -260,6 +264,7 @@ export const createInternal = (
260264
const run = async (context: Context, initialInput: HookInputEncode) => {
261265
const result = await Core.anyware.run({
262266
initialInput,
267+
retryingExtension: context.retry,
263268
extensions: context.extensions,
264269
}) as GraffleExecutionResult
265270
return handleReturn(context, result)
@@ -296,6 +301,9 @@ export const createInternal = (
296301
// todo test that adding extensions returns a copy of client
297302
return createInternal(input, { extensions: [...state.extensions, extension] })
298303
},
304+
retry: (extension: Anyware.Extension2<Core.Core, { retrying: true }>) => {
305+
return createInternal(input, { ...state, retry: extension })
306+
},
299307
}
300308

301309
// todo extract this into constructor "create typed client"

src/lib/anyware/__.test-d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* eslint-disable */
22

3+
import { run } from 'node:test'
34
import { expectTypeOf, test } from 'vitest'
45
import { Result } from '../../../tests/_/schema/generated/SchemaRuntime.js'
56
import { ContextualError } from '../errors/ContextualError.js'
@@ -32,6 +33,16 @@ test('run', () => {
3233
(input: {
3334
initialInput: InputA
3435
options?: Anyware.Options
36+
retryingExtension?: (input: {
37+
a: SomeHook<
38+
(input?: InputA) => MaybePromise<
39+
Error | {
40+
b: SomeHook<(input?: InputB) => MaybePromise<Error | Result>>
41+
}
42+
>
43+
>
44+
b: SomeHook<(input?: InputB) => MaybePromise<Error | Result>>
45+
}) => Promise<Result>
3546
extensions: ((input: {
3647
a: SomeHook<
3748
(input?: InputA) => MaybePromise<{

src/lib/anyware/getEntrypoint.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// import type { Extension, HookName } from '../../layers/5_client/extension/types.js'
22
import { analyzeFunction } from '../analyzeFunction.js'
33
import { ContextualError } from '../errors/ContextualError.js'
4-
import type { ExtensionInput, HookName } from './main.js'
4+
import type { HookName, NonRetryingExtensionInput } from './main.js'
55

66
export class ErrorAnywareExtensionEntrypoint extends ContextualError<
77
'ErrorGraffleExtensionEntryHook',
@@ -25,7 +25,7 @@ export type ExtensionEntryHookIssue = typeof ExtensionEntryHookIssue[keyof typeo
2525

2626
export const getEntrypoint = (
2727
hookNames: readonly string[],
28-
extension: ExtensionInput,
28+
extension: NonRetryingExtensionInput,
2929
): ErrorAnywareExtensionEntrypoint | HookName => {
3030
const x = analyzeFunction(extension)
3131
if (x.parameters.length > 1) {

src/lib/anyware/lib.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const defaultFunctionName = `anonymous`

src/lib/anyware/main.test.ts

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
/* eslint-disable */
22

33
import { describe, expect, test, vi } from 'vitest'
4+
import { Errors } from '../errors/__.js'
45
import type { ContextualError } from '../errors/ContextualError.js'
5-
import { core, initialInput, oops, run, runWithOptions } from './specHelpers.js'
6+
import { createRetryingExtension } from './main.js'
7+
import { core, oops, run, runWithOptions } from './specHelpers.js'
68

79
describe(`no extensions`, () => {
810
test(`passthrough to implementation`, async () => {
@@ -203,7 +205,7 @@ describe(`errors`, () => {
203205
`)
204206
})
205207

206-
test(`implementation throws`, async () => {
208+
test(`if implementation fails, without extensions, result is the error`, async () => {
207209
core.hooks.a.mockReset().mockRejectedValueOnce(oops)
208210
const result = await run() as ContextualError
209211
expect({
@@ -221,4 +223,88 @@ describe(`errors`, () => {
221223
}
222224
`)
223225
})
226+
test('calling a hook twice leads to clear error', async () => {
227+
let neverRan = true
228+
const result = await run(async ({ a }) => {
229+
await a()
230+
await a()
231+
neverRan = false
232+
}) as ContextualError
233+
expect(neverRan).toBe(true)
234+
const cause = result.cause as ContextualError
235+
expect(cause.message).toMatchInlineSnapshot(
236+
`"Only a retrying extension can retry hooks."`,
237+
)
238+
expect(cause.context).toMatchInlineSnapshot(`
239+
{
240+
"extensionsAfter": [],
241+
"hookName": "a",
242+
}
243+
`)
244+
})
245+
})
246+
247+
describe('retrying extension', () => {
248+
test('if hook fails, extension can retry, then short-circuit', async () => {
249+
core.hooks.a.mockReset().mockRejectedValueOnce(oops).mockResolvedValueOnce(1)
250+
const result = await run(createRetryingExtension(async function foo({ a }) {
251+
const result1 = await a()
252+
expect(result1).toEqual(oops)
253+
const result2 = await a()
254+
expect(typeof result2.b).toEqual('function')
255+
expect(result2.b.input).toEqual(1)
256+
return result2.b.input
257+
}))
258+
expect(result).toEqual(1)
259+
})
260+
261+
describe('errors', () => {
262+
test('not last extension', async () => {
263+
const result = await run(
264+
createRetryingExtension(async function foo({ a }) {
265+
return a()
266+
}),
267+
async function bar({ a }) {
268+
return a()
269+
},
270+
)
271+
expect(result).toMatchInlineSnapshot(`[ContextualError: Only the last extension can retry hooks.]`)
272+
expect((result as Errors.ContextualError).context).toMatchInlineSnapshot(`
273+
{
274+
"extensionsAfter": [
275+
{
276+
"name": "bar",
277+
},
278+
],
279+
}
280+
`)
281+
})
282+
test('call hook twice even though it succeeded the first time', async () => {
283+
let neverRan = true
284+
const result = await run(
285+
createRetryingExtension(async function foo({ a }) {
286+
const result1 = await a()
287+
expect('b' in result1).toBe(true)
288+
await a() // <-- Extension bug here under test.
289+
neverRan = false
290+
}),
291+
)
292+
expect(neverRan).toBe(true)
293+
expect(result).toMatchInlineSnapshot(
294+
`[ContextualError: There was an error in the extension "foo".]`,
295+
)
296+
expect((result as Errors.ContextualError).context).toMatchInlineSnapshot(
297+
`
298+
{
299+
"extensionName": "foo",
300+
"hookName": "a",
301+
"source": "extension",
302+
}
303+
`,
304+
)
305+
expect((result as Errors.ContextualError).cause).toMatchInlineSnapshot(
306+
`[ContextualError: Only after failure can a hook be called again by a retrying extension.]`,
307+
)
308+
})
309+
})
224310
})

0 commit comments

Comments
 (0)