Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: anyware hook retries #904

Merged
merged 5 commits into from
Jun 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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