Skip to content

Commit

Permalink
feat: anyware hook retries (#904)
Browse files Browse the repository at this point in the history
  • Loading branch information
jasonkuhrt authored Jun 9, 2024
1 parent 1b5e7fc commit c91bbc3
Show file tree
Hide file tree
Showing 10 changed files with 618 additions and 254 deletions.
26 changes: 24 additions & 2 deletions src/layers/5_client/client.extend.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
/* eslint-disable */
import { ExecutionResult } from 'graphql'
import { describe, expect } from 'vitest'
import { db } from '../../../tests/_/db.js'
import { createResponse, test } from '../../../tests/_/helpers.js'
import { Graffle } from '../../../tests/_/schema/generated/__.js'
import { GraphQLExecutionResult } from '../../legacy/lib/graphql.js'
import { oops } from '../../lib/anyware/specHelpers.js'

const client = Graffle.create({ schema: 'https://foo', returnMode: 'dataAndErrors' })
const headers = { 'x-foo': 'bar' }
Expand Down Expand Up @@ -38,3 +37,26 @@ describe(`entrypoint request`, () => {
expect(await client2.query.id()).toEqual(db.id)
})
})

test('can retry failed request', async ({ fetch }) => {
fetch
.mockImplementationOnce(async () => {
throw oops
})
.mockImplementationOnce(async () => {
throw oops
})
.mockImplementationOnce(async () => {
return createResponse({ data: { id: db.id } })
})
const client2 = client.retry(async ({ exchange }) => {
let result = await exchange()
while (result instanceof Error) {
result = await exchange()
}
return result
})
const result = await client2.query.id()
expect(result).toEqual(db.id)
expect(fetch.mock.calls.length).toEqual(3)
})
10 changes: 9 additions & 1 deletion src/layers/5_client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type SelectionSetOrIndicator = 0 | 1 | boolean | object
export type SelectionSetOrArgs = object

export interface Context {
retry: undefined | Anyware.Extension2<Core.Core, { retrying: true }>
extensions: Anyware.Extension2<Core.Core>[]
config: Config
}
Expand All @@ -65,6 +66,7 @@ export type Client<$Index extends Schema.Index | null, $Config extends Config> =
)
& {
extend: (extension: Anyware.Extension2<Core.Core>) => Client<$Index, $Config>
retry: (extension: Anyware.Extension2<Core.Core, { retrying: true }>) => Client<$Index, $Config>
}

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

export const create: Create = (
input_,
) => createInternal(input_, { extensions: [] })
) => createInternal(input_, { extensions: [], retry: undefined })

interface CreateState {
retry?: Anyware.Extension2<Core.Core, { retrying: true }>
extensions: Anyware.Extension2<Core.Core>[]
}

Expand Down Expand Up @@ -251,6 +254,7 @@ export const createInternal = (
}

const context: Context = {
retry: state.retry,
extensions: state.extensions,
config: {
returnMode,
Expand All @@ -260,6 +264,7 @@ export const createInternal = (
const run = async (context: Context, initialInput: HookInputEncode) => {
const result = await Core.anyware.run({
initialInput,
retryingExtension: context.retry,
extensions: context.extensions,
}) as GraffleExecutionResult
return handleReturn(context, result)
Expand Down Expand Up @@ -296,6 +301,9 @@ export const createInternal = (
// todo test that adding extensions returns a copy of client
return createInternal(input, { extensions: [...state.extensions, extension] })
},
retry: (extension: Anyware.Extension2<Core.Core, { retrying: true }>) => {
return createInternal(input, { ...state, retry: extension })
},
}

// todo extract this into constructor "create typed client"
Expand Down
11 changes: 11 additions & 0 deletions src/lib/anyware/__.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable */

import { run } from 'node:test'
import { expectTypeOf, test } from 'vitest'
import { Result } from '../../../tests/_/schema/generated/SchemaRuntime.js'
import { ContextualError } from '../errors/ContextualError.js'
Expand Down Expand Up @@ -32,6 +33,16 @@ test('run', () => {
(input: {
initialInput: InputA
options?: Anyware.Options
retryingExtension?: (input: {
a: SomeHook<
(input?: InputA) => MaybePromise<
Error | {
b: SomeHook<(input?: InputB) => MaybePromise<Error | Result>>
}
>
>
b: SomeHook<(input?: InputB) => MaybePromise<Error | Result>>
}) => Promise<Result>
extensions: ((input: {
a: SomeHook<
(input?: InputA) => MaybePromise<{
Expand Down
4 changes: 2 additions & 2 deletions src/lib/anyware/getEntrypoint.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// import type { Extension, HookName } from '../../layers/5_client/extension/types.js'
import { analyzeFunction } from '../analyzeFunction.js'
import { ContextualError } from '../errors/ContextualError.js'
import type { ExtensionInput, HookName } from './main.js'
import type { HookName, NonRetryingExtensionInput } from './main.js'

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

export const getEntrypoint = (
hookNames: readonly string[],
extension: ExtensionInput,
extension: NonRetryingExtensionInput,
): ErrorAnywareExtensionEntrypoint | HookName => {
const x = analyzeFunction(extension)
if (x.parameters.length > 1) {
Expand Down
1 change: 1 addition & 0 deletions src/lib/anyware/lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const defaultFunctionName = `anonymous`
90 changes: 88 additions & 2 deletions src/lib/anyware/main.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/* eslint-disable */

import { describe, expect, test, vi } from 'vitest'
import { Errors } from '../errors/__.js'
import type { ContextualError } from '../errors/ContextualError.js'
import { core, initialInput, oops, run, runWithOptions } from './specHelpers.js'
import { createRetryingExtension } from './main.js'
import { core, oops, run, runWithOptions } from './specHelpers.js'

describe(`no extensions`, () => {
test(`passthrough to implementation`, async () => {
Expand Down Expand Up @@ -203,7 +205,7 @@ describe(`errors`, () => {
`)
})

test(`implementation throws`, async () => {
test(`if implementation fails, without extensions, result is the error`, async () => {
core.hooks.a.mockReset().mockRejectedValueOnce(oops)
const result = await run() as ContextualError
expect({
Expand All @@ -221,4 +223,88 @@ describe(`errors`, () => {
}
`)
})
test('calling a hook twice leads to clear error', async () => {
let neverRan = true
const result = await run(async ({ a }) => {
await a()
await a()
neverRan = false
}) as ContextualError
expect(neverRan).toBe(true)
const cause = result.cause as ContextualError
expect(cause.message).toMatchInlineSnapshot(
`"Only a retrying extension can retry hooks."`,
)
expect(cause.context).toMatchInlineSnapshot(`
{
"extensionsAfter": [],
"hookName": "a",
}
`)
})
})

describe('retrying extension', () => {
test('if hook fails, extension can retry, then short-circuit', async () => {
core.hooks.a.mockReset().mockRejectedValueOnce(oops).mockResolvedValueOnce(1)
const result = await run(createRetryingExtension(async function foo({ a }) {
const result1 = await a()
expect(result1).toEqual(oops)
const result2 = await a()
expect(typeof result2.b).toEqual('function')
expect(result2.b.input).toEqual(1)
return result2.b.input
}))
expect(result).toEqual(1)
})

describe('errors', () => {
test('not last extension', async () => {
const result = await run(
createRetryingExtension(async function foo({ a }) {
return a()
}),
async function bar({ a }) {
return a()
},
)
expect(result).toMatchInlineSnapshot(`[ContextualError: Only the last extension can retry hooks.]`)
expect((result as Errors.ContextualError).context).toMatchInlineSnapshot(`
{
"extensionsAfter": [
{
"name": "bar",
},
],
}
`)
})
test('call hook twice even though it succeeded the first time', async () => {
let neverRan = true
const result = await run(
createRetryingExtension(async function foo({ a }) {
const result1 = await a()
expect('b' in result1).toBe(true)
await a() // <-- Extension bug here under test.
neverRan = false
}),
)
expect(neverRan).toBe(true)
expect(result).toMatchInlineSnapshot(
`[ContextualError: There was an error in the extension "foo".]`,
)
expect((result as Errors.ContextualError).context).toMatchInlineSnapshot(
`
{
"extensionName": "foo",
"hookName": "a",
"source": "extension",
}
`,
)
expect((result as Errors.ContextualError).cause).toMatchInlineSnapshot(
`[ContextualError: Only after failure can a hook be called again by a retrying extension.]`,
)
})
})
})
Loading

0 comments on commit c91bbc3

Please sign in to comment.