diff --git a/.changeset/cold-monkeys-approve.md b/.changeset/cold-monkeys-approve.md new file mode 100644 index 000000000..0aa96457e --- /dev/null +++ b/.changeset/cold-monkeys-approve.md @@ -0,0 +1,5 @@ +--- +'@graphql-tools/batch-execute': patch +--- + +Spread sync errors into an array with the same size of the requests to satisfy underlying DataLoader implementation to throw the error correctly diff --git a/.changeset/giant-paws-battle.md b/.changeset/giant-paws-battle.md new file mode 100644 index 000000000..5087d3fa7 --- /dev/null +++ b/.changeset/giant-paws-battle.md @@ -0,0 +1,5 @@ +--- +'@graphql-hive/gateway-runtime': patch +--- + +If metadata is included the result with `includeExtensionMetadata`, `cost.estimated` should always be added to the result extensions even if no cost is calculated. diff --git a/packages/batch-execute/src/createBatchingExecutor.ts b/packages/batch-execute/src/createBatchingExecutor.ts index f6c8f4d9a..61d360bf0 100644 --- a/packages/batch-execute/src/createBatchingExecutor.ts +++ b/packages/batch-execute/src/createBatchingExecutor.ts @@ -49,7 +49,7 @@ function createLoadFn( ): PromiseLike> { if (requests.length === 1 && requests[0]) { const request = requests[0]; - return fakePromise( + return fakePromise( handleMaybePromise( () => executor(request), (result) => [result], @@ -58,7 +58,7 @@ function createLoadFn( ); } const mergedRequests = mergeRequests(requests, extensionsReducer); - return fakePromise( + return fakePromise( handleMaybePromise( () => executor(mergedRequests), (resultBatches) => { @@ -69,6 +69,7 @@ function createLoadFn( } return splitResult(resultBatches, requests.length); }, + (err) => requests.map(() => err), ), ); }; diff --git a/packages/runtime/src/plugins/useDemandControl.ts b/packages/runtime/src/plugins/useDemandControl.ts index 868b9f604..e55adf7e5 100644 --- a/packages/runtime/src/plugins/useDemandControl.ts +++ b/packages/runtime/src/plugins/useDemandControl.ts @@ -104,33 +104,31 @@ export function useDemandControl>({ }, onExecutionResult({ result, setResult, context }) { if (includeExtensionMetadata) { - const costByContext = costByContextMap.get(context); - if (costByContext) { - if (isAsyncIterable(result)) { - setResult( - mapAsyncIterator(result, (value) => ({ - ...value, - extensions: { - ...(value.extensions || {}), - cost: { - estimated: costByContext, - max: maxCost, - }, - }, - })), - ); - } else { - setResult({ - ...(result || {}), + const costByContext = costByContextMap.get(context) || 0; + if (isAsyncIterable(result)) { + setResult( + mapAsyncIterator(result, (value) => ({ + ...value, extensions: { - ...(result?.extensions || {}), + ...(value.extensions || {}), cost: { estimated: costByContext, max: maxCost, }, }, - }); - } + })), + ); + } else { + setResult({ + ...(result || {}), + extensions: { + ...(result?.extensions || {}), + cost: { + estimated: costByContext, + max: maxCost, + }, + }, + }); } } }, diff --git a/packages/runtime/tests/demand-control.test.ts b/packages/runtime/tests/demand-control.test.ts index 0a178d31b..a33e9534c 100644 --- a/packages/runtime/tests/demand-control.test.ts +++ b/packages/runtime/tests/demand-control.test.ts @@ -753,6 +753,11 @@ describe('Demand Control', () => { path: ['items'], }, ], + extensions: { + cost: { + estimated: 0, + }, + }, }); }); it('@listSize(slicingArguments:, requireOneSlicingArgument:false)', async () => { @@ -1016,4 +1021,169 @@ describe('Demand Control', () => { }, }); }); + + it('returns cost even if it does not hit the subgraph', async () => { + const subgraph = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + type Query { + foo: String + } + `), + }); + await using subgraphServer = createYoga({ + schema: subgraph, + }); + await using gateway = createGatewayRuntime({ + supergraph: await composeLocalSchemasWithApollo([ + { + name: 'subgraph', + schema: subgraph, + url: 'http://subgraph/graphql', + }, + ]), + plugins: () => [ + // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch + useCustomFetch(subgraphServer.fetch), + useDemandControl({ + includeExtensionMetadata: true, + }), + ], + }); + const query = /* GraphQL */ ` + query EmptyQuery { + __typename + a: __typename + } + `; + const response = await gateway.fetch('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query }), + }); + const result = await response.json(); + expect(result).toEqual({ + data: { + __typename: 'Query', + a: 'Query', + }, + extensions: { + cost: { + estimated: 0, + }, + }, + }); + }); + + it('handles batched requests', async () => { + const subgraph = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + type Query { + foo: Foo + bar: Bar + } + + type Foo { + id: ID + } + + type Bar { + id: ID + } + `), + resolvers: { + Query: { + foo: async () => ({ id: 'foo' }), + bar: async () => ({ id: 'bar' }), + }, + }, + }); + await using subgraphServer = createYoga({ + schema: subgraph, + }); + await using gateway = createGatewayRuntime({ + supergraph: await composeLocalSchemasWithApollo([ + { + name: 'subgraph', + schema: subgraph, + url: 'http://subgraph/graphql', + }, + ]), + plugins: () => [ + // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch + useCustomFetch(subgraphServer.fetch), + useDemandControl({ + includeExtensionMetadata: true, + maxCost: 1, + }), + ], + }); + const query = /* GraphQL */ ` + query FooQuery { + foo { + id + } + bar { + id + } + } + `; + const response = await gateway.fetch('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query }), + }); + const result = await response.json(); + expect(result).toEqual({ + data: { + foo: null, + bar: null, + }, + errors: [ + { + extensions: { + code: 'COST_ESTIMATED_TOO_EXPENSIVE', + cost: { + estimated: 2, + max: 1, + }, + }, + locations: [ + { + column: 9, + line: 3, + }, + ], + message: 'Operation estimated cost 2 exceeded configured maximum 1', + path: ['foo'], + }, + { + extensions: { + code: 'COST_ESTIMATED_TOO_EXPENSIVE', + cost: { + estimated: 2, + max: 1, + }, + }, + locations: [ + { + column: 9, + line: 6, + }, + ], + message: 'Operation estimated cost 2 exceeded configured maximum 1', + path: ['bar'], + }, + ], + extensions: { + cost: { + estimated: 2, + max: 1, + }, + }, + }); + }); });