diff --git a/.changeset/timeout-graphql-invocations.md b/.changeset/timeout-graphql-invocations.md new file mode 100644 index 000000000..a09a79cca --- /dev/null +++ b/.changeset/timeout-graphql-invocations.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Abort GraphQL tool calls that exceed the configured invocation timeout instead of waiting indefinitely for an upstream response. diff --git a/packages/plugins/graphql/src/sdk/errors.ts b/packages/plugins/graphql/src/sdk/errors.ts index 2f0b61bba..e85af913f 100644 --- a/packages/plugins/graphql/src/sdk/errors.ts +++ b/packages/plugins/graphql/src/sdk/errors.ts @@ -19,6 +19,8 @@ export class GraphqlExtractionError extends Schema.TaggedErrorClass; + readonly reason?: "invocation_timeout"; + readonly timeoutMs?: number; readonly cause?: unknown; }> {} diff --git a/packages/plugins/graphql/src/sdk/index.ts b/packages/plugins/graphql/src/sdk/index.ts index a70a6b1ef..6819c0a39 100644 --- a/packages/plugins/graphql/src/sdk/index.ts +++ b/packages/plugins/graphql/src/sdk/index.ts @@ -1,6 +1,11 @@ export { introspect, parseIntrospectionJson } from "./introspect"; export { extract, type ExtractionOutput } from "./extract"; -export { invoke, invokeWithLayer } from "./invoke"; +export { + GRAPHQL_INVOCATION_TIMEOUT_MS, + invoke, + invokeWithLayer, + type GraphqlInvokeOptions, +} from "./invoke"; export { describeGraphqlAuthMethods, graphqlPlugin, diff --git a/packages/plugins/graphql/src/sdk/invocation-timeout.test.ts b/packages/plugins/graphql/src/sdk/invocation-timeout.test.ts new file mode 100644 index 000000000..567b865a6 --- /dev/null +++ b/packages/plugins/graphql/src/sdk/invocation-timeout.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Option } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; +import { createServer } from "node:http"; +import type { AddressInfo, Socket } from "node:net"; +import { introspectionFromSchema } from "graphql"; + +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ToolAddress, + createExecutor, +} from "@executor-js/sdk"; +import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; + +import { makeGreetingGraphqlSchema } from "../testing"; +import { graphqlPlugin } from "./plugin"; + +const INVOCATION_TIMEOUT_MS = 100; + +const startHangingResponseServer = (closed: Deferred.Deferred) => + Effect.acquireRelease( + Effect.callback<{ endpoint: string; close: () => void }>((resume) => { + const sockets = new Set(); + const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.write('{"data":'); + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => { + sockets.delete(socket); + Effect.runFork(Deferred.succeed(closed, undefined)); + }); + }); + server.listen(0, "127.0.0.1", () => { + const port = (server.address() as AddressInfo).port; + resume( + Effect.succeed({ + endpoint: `http://127.0.0.1:${port}/graphql`, + close: () => { + for (const socket of sockets) socket.destroy(); + server.close(); + }, + }), + ); + }); + }), + (server) => Effect.sync(() => server.close()), + ); + +const introspectionJson = JSON.stringify({ + data: introspectionFromSchema(makeGreetingGraphqlSchema({ includeMutation: false })), +}); + +describe("GraphQL invocation timeout", () => { + it.live("aborts a hanging response body and returns an actionable tool failure", () => + Effect.gen(function* () { + const closed = yield* Deferred.make(); + const server = yield* startHangingResponseServer(closed); + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [ + memoryCredentialsPlugin(), + graphqlPlugin({ + httpClientLayer: FetchHttpClient.layer, + invokeOptions: { timeoutMs: INVOCATION_TIMEOUT_MS }, + }), + ] as const, + }), + ); + + yield* executor.graphql.addIntegration({ + endpoint: server.endpoint, + slug: "invocation_timeout", + introspectionJson, + }); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make("invocation_timeout"), + template: AuthTemplateSlug.make("none"), + value: "unused", + }); + + const startedAt = Date.now(); + const resultOption = yield* executor + .execute(ToolAddress.make("tools.invocation_timeout.org.main.query.hello"), {}) + .pipe(Effect.timeoutOption(1_000)); + const elapsedMs = Date.now() - startedAt; + const socketClosed = yield* Deferred.await(closed).pipe(Effect.timeoutOption(1_000)); + const result = Option.getOrNull(resultOption); + + expect(elapsedMs).toBeGreaterThanOrEqual(INVOCATION_TIMEOUT_MS - 25); + expect(elapsedMs).toBeLessThan(1_000); + expect(Option.isSome(socketClosed)).toBe(true); + expect(result).toMatchObject({ + ok: false, + error: { + code: "graphql_request_timeout", + message: expect.stringContaining("GraphQL upstream did not complete within 100ms"), + }, + }); + }), + ); +}); diff --git a/packages/plugins/graphql/src/sdk/invoke.ts b/packages/plugins/graphql/src/sdk/invoke.ts index 819acefb0..b3ae55e20 100644 --- a/packages/plugins/graphql/src/sdk/invoke.ts +++ b/packages/plugins/graphql/src/sdk/invoke.ts @@ -21,6 +21,20 @@ export const endpointForTelemetry = (endpoint: string): string => { return url.toString(); }; +// Below Cloudflare's approximate 125-second subrequest limit while preserving +// slow upstream requests that Executor's HTTP transports support. +export const GRAPHQL_INVOCATION_TIMEOUT_MS = 110_000; + +export interface GraphqlInvokeOptions { + readonly timeoutMs?: number; +} + +const formatTimeout = (timeoutMs: number): string => + timeoutMs % 1_000 === 0 ? `${timeoutMs / 1_000}s` : `${timeoutMs}ms`; + +const invocationTimeoutMessage = (timeoutMs: number): string => + `GraphQL upstream did not complete within ${formatTimeout(timeoutMs)}. The request was aborted. Retry the operation or verify that the endpoint is responsive.`; + /** The operation string to send for a call. A caller-supplied `select` overrides * the default scalar-leaf selection: it is spliced into the field's selection * set (`field {