diff --git a/.changeset/cold-bags-confess.md b/.changeset/cold-bags-confess.md new file mode 100644 index 0000000000..514df6b8ab --- /dev/null +++ b/.changeset/cold-bags-confess.md @@ -0,0 +1,67 @@ +--- +'@graphql-hive/gateway-runtime': minor +--- + +New Hive CDN mirror and circuit breaker + +Hive CDN introduced a new CDN mirror and circuit breaker to mitigate the risk related to Cloudflare +services failures. + +You can now provide multiple endpoint in Hive Console related features, and configure the circuit +breaker handling CDN failure and how it switches to the CDN mirror. + +### Usage + +To enable this feature, please provide the mirror endpoint in `supergraph` and `persistedDocument` +options: + +```diff +import { defineConfig } from '@graphql-hive/gateway' + +export const gatewayConfig = defineConfig({ + supergraph: { + type: 'hive', +- endpoint: 'https://cdn.graphql-hive.com/artifacts/v1//supergraph', ++ endpoint: [ ++ 'https://cdn.graphql-hive.com/artifacts/v1//supergraph', ++ 'https://cdn-mirror.graphql-hive.com/artifacts/v1//supergraph' ++ ] + }, + + persistedDocuments: { +- endpoint: 'https://cdn.graphql-hive.com/', ++ endpoint: [ ++ 'https://cdn.graphql-hive.com/', ++ 'https://cdn-mirror.graphql-hive.com/' ++ ] + } +}) +``` + +### Configuration + +The circuit breaker has production ready default configuration, but you customize its behavior: + +```ts +import { defineConfig, CircuitBreakerConfiguration } from '@graphql-hive/gateway'; + +const circuitBreaker: CircuitBreakerConfiguration = { + resetTimeout: 30_000; // 30s + errorThresholdPercentage: 50; + volumeThreshold: 5; +} + +export const gatewayConfig = defineConfig({ + supergraph: { + type: 'hive', + endpoint: [...], + circuitBreaker, + }, + + persistedDocuments: { + type: 'hive', + endpoint: [...], + circuitBreaker, + }, +}); +``` diff --git a/e2e/cloudflare-workers/wrangler.toml b/e2e/cloudflare-workers/wrangler.toml index ac2d3da4a5..45e2220df0 100644 --- a/e2e/cloudflare-workers/wrangler.toml +++ b/e2e/cloudflare-workers/wrangler.toml @@ -7,4 +7,4 @@ compatibility_date = "2024-01-01" "@graphql-tools/batch-delegate" = "../../packages/batch-delegate/src/index.ts" "@graphql-tools/delegate" = "../../packages/delegate/src/index.ts" "@graphql-hive/signal" = "../../packages/signal/src/index.ts" -"@graphql-hive/logger" = "../../packages/logger/src/index.ts" \ No newline at end of file +"@graphql-hive/logger" = "../../packages/logger/src/index.ts" diff --git a/e2e/self-hosting-hive/self-hosting-hive.e2e.ts b/e2e/self-hosting-hive/self-hosting-hive.e2e.ts index 43796e19f2..8f7f478867 100644 --- a/e2e/self-hosting-hive/self-hosting-hive.e2e.ts +++ b/e2e/self-hosting-hive/self-hosting-hive.e2e.ts @@ -60,7 +60,7 @@ describe('Self Hosting Hive', () => { /\[hiveSupergraphFetcher\] GET .* succeeded with status 200/, ); expect(gwLogs).toMatch( - /\[useHiveConsole\] POST .* succeeded with status 200/, + /\[useHiveConsole\] POST .*\/usage .* succeeded with status 200/, ); }); }); diff --git a/packages/runtime/src/createGatewayRuntime.ts b/packages/runtime/src/createGatewayRuntime.ts index 0bbde6b2e0..d23e74bf78 100644 --- a/packages/runtime/src/createGatewayRuntime.ts +++ b/packages/runtime/src/createGatewayRuntime.ts @@ -5,10 +5,7 @@ import { } from '@envelop/core'; import { useDisableIntrospection } from '@envelop/disable-introspection'; import { useGenericAuth } from '@envelop/generic-auth'; -import { - createSchemaFetcher, - createSupergraphSDLFetcher, -} from '@graphql-hive/core'; +import { createCDNArtifactFetcher, joinUrl } from '@graphql-hive/core'; import { LegacyLogger } from '@graphql-hive/logger'; import type { OnDelegationPlanHook, @@ -230,6 +227,7 @@ export function createGatewayRuntime< endpoint: config.persistedDocuments.endpoint, accessToken: config.persistedDocuments.token, }, + circuitBreaker: config.persistedDocuments.circuitBreaker, // @ts-expect-error - Hive Console plugin options are not compatible yet allowArbitraryDocuments: allowArbitraryDocumentsForPersistedDocuments, }, @@ -270,7 +268,7 @@ export function createGatewayRuntime< clearTimeout(currentTimeout); } if (pollingInterval) { - currentTimeout = setTimeout(schemaFetcher, pollingInterval); + currentTimeout = setTimeout(schemaFetcher.fetch, pollingInterval); } } function pausePolling() { @@ -280,7 +278,10 @@ export function createGatewayRuntime< } let lastFetchedSdl: string | undefined; let initialFetch$: MaybePromise; - let schemaFetcher: () => MaybePromise; + let schemaFetcher: { + fetch: () => MaybePromise; + dispose?: () => void | PromiseLike; + }; if ( config.schema && @@ -288,25 +289,45 @@ export function createGatewayRuntime< 'type' in config.schema ) { // hive cdn - const { endpoint, key } = config.schema; - const fetcher = createSchemaFetcher({ - endpoint, - key, + const { endpoint, key, circuitBreaker } = config.schema; + function ensureSdl(endpoint: string): string { + // the services path returns the sdl and the service name, + // we only care about the sdl so always use the sdl + endpoint = endpoint.replace(/\/services$/, '/sdl'); + if (!/\/sdl(\.graphql)*$/.test(endpoint)) { + // ensure ends with /sdl + endpoint = joinUrl(endpoint, 'sdl'); + } + return endpoint; + } + const fetcher = createCDNArtifactFetcher({ + endpoint: Array.isArray(endpoint) + ? // no endpoint.map just to make ts happy without casting + [ensureSdl(endpoint[0]), ensureSdl(endpoint[1])] + : ensureSdl(endpoint), + circuitBreaker, + accessKey: key, logger: configContext.log.child('[hiveSchemaFetcher] '), }); - schemaFetcher = function fetchSchemaFromCDN() { - pausePolling(); - initialFetch$ = handleMaybePromise(fetcher, ({ sdl }) => { - if (lastFetchedSdl == null || lastFetchedSdl !== sdl) { - unifiedGraph = buildSchema(sdl, { - assumeValid: true, - assumeValidSDL: true, - }); - } - continuePolling(); - return true; - }); - return initialFetch$; + schemaFetcher = { + fetch: function fetchSchemaFromCDN() { + pausePolling(); + initialFetch$ = handleMaybePromise( + fetcher.fetch, + ({ contents }): true => { + if (lastFetchedSdl == null || lastFetchedSdl !== contents) { + unifiedGraph = buildSchema(contents, { + assumeValid: true, + assumeValidSDL: true, + }); + } + continuePolling(); + return true; + }, + ); + return initialFetch$; + }, + dispose: () => fetcher.dispose(), }; } else if (config.schema) { // local or remote @@ -316,60 +337,67 @@ export function createGatewayRuntime< delete config.pollingInterval; } - schemaFetcher = function fetchSchema() { - pausePolling(); - initialFetch$ = handleMaybePromise( - () => - handleUnifiedGraphConfig( - // @ts-expect-error TODO: what's up with type narrowing - config.schema, - configContext, - ), - (schema) => { - if (isSchema(schema)) { - unifiedGraph = schema; - } else if (isDocumentNode(schema)) { - unifiedGraph = buildASTSchema(schema, { - assumeValid: true, - assumeValidSDL: true, - }); - } else { - unifiedGraph = buildSchema(schema, { - noLocation: true, - assumeValid: true, - assumeValidSDL: true, - }); - } - continuePolling(); - return true; - }, - ); - return initialFetch$; + schemaFetcher = { + fetch: function fetchSchema() { + pausePolling(); + initialFetch$ = handleMaybePromise( + () => + handleUnifiedGraphConfig( + // @ts-expect-error TODO: what's up with type narrowing + config.schema, + configContext, + ), + (schema) => { + if (isSchema(schema)) { + unifiedGraph = schema; + } else if (isDocumentNode(schema)) { + unifiedGraph = buildASTSchema(schema, { + assumeValid: true, + assumeValidSDL: true, + }); + } else { + unifiedGraph = buildSchema(schema, { + noLocation: true, + assumeValid: true, + assumeValidSDL: true, + }); + } + continuePolling(); + return true; + }, + ); + return initialFetch$; + }, }; } else { // introspect endpoint - schemaFetcher = function fetchSchemaWithExecutor() { - pausePolling(); - return handleMaybePromise( - () => - schemaFromExecutor(proxyExecutor, configContext, { - assumeValid: true, - }), - (schema) => { - unifiedGraph = schema; - continuePolling(); - return true; - }, - ); + schemaFetcher = { + fetch: function fetchSchemaWithExecutor() { + pausePolling(); + return handleMaybePromise( + () => + schemaFromExecutor(proxyExecutor, configContext, { + assumeValid: true, + }), + (schema) => { + unifiedGraph = schema; + continuePolling(); + return true; + }, + ); + }, }; } - const instrumentedFetcher = schemaFetcher; - schemaFetcher = (...args) => - getInstrumented(null).asyncFn( - instrumentation?.schema, - instrumentedFetcher, - )(...args); + const instrumentedFetcher = schemaFetcher.fetch; + schemaFetcher = { + ...schemaFetcher, + fetch: (...args) => + getInstrumented(null).asyncFn( + instrumentation?.schema, + instrumentedFetcher, + )(...args), + }; getSchema = () => { if (unifiedGraph != null) { @@ -381,11 +409,11 @@ export function createGatewayRuntime< () => unifiedGraph, ); } - return handleMaybePromise(schemaFetcher, () => unifiedGraph); + return handleMaybePromise(schemaFetcher.fetch, () => unifiedGraph); }; const shouldSkipValidation = 'skipValidation' in config ? config.skipValidation : false; - const executorPlugin: GatewayPlugin = { + unifiedGraphPlugin = { onValidate({ params, setResult }) { if (shouldSkipValidation || !params.schema) { setResult([]); @@ -393,10 +421,12 @@ export function createGatewayRuntime< }, onDispose() { pausePolling(); - return transportExecutorStack.disposeAsync(); + return handleMaybePromise( + () => transportExecutorStack.disposeAsync(), + () => schemaFetcher.dispose?.(), + ); }, }; - unifiedGraphPlugin = executorPlugin; readinessChecker = () => handleMaybePromise( () => @@ -410,7 +440,7 @@ export function createGatewayRuntime< schemaInvalidator = () => { // @ts-expect-error TODO: this is illegal but somehow we want it unifiedGraph = undefined; - initialFetch$ = schemaFetcher(); + initialFetch$ = schemaFetcher.fetch(); }; } else if ('subgraph' in config) { const subgraphInConfig = config.subgraph; @@ -641,40 +671,57 @@ export function createGatewayRuntime< }, }; } /** 'supergraph' in config */ else { - let unifiedGraphFetcher: ( - transportCtx: TransportContext, - ) => MaybePromise; + let unifiedGraphFetcher: { + fetch: ( + transportCtx: TransportContext, + ) => MaybePromise; + dispose?: () => void | PromiseLike; + }; + if (typeof config.supergraph === 'object' && 'type' in config.supergraph) { if (config.supergraph.type === 'hive') { // hive cdn - const { endpoint, key } = config.supergraph; - const fetcher = createSupergraphSDLFetcher({ - endpoint, - key, - logger: LegacyLogger.from( - configContext.log.child('[hiveSupergraphFetcher] '), - ), - + const { endpoint, key, circuitBreaker } = config.supergraph; + function ensureSupergraph(endpoint: string): string { + if (!/\/supergraph(\.graphql)*$/.test(endpoint)) { + // ensure ends with /supergraph + endpoint = joinUrl(endpoint, 'supergraph'); + } + return endpoint; + } + const fetcher = createCDNArtifactFetcher({ + endpoint: Array.isArray(endpoint) + ? // no endpoint.map just to make ts happy without casting + [ensureSupergraph(endpoint[0]), ensureSupergraph(endpoint[1])] + : ensureSupergraph(endpoint), + accessKey: key, + logger: configContext.log.child('[hiveSupergraphFetcher] '), // @ts-expect-error - MeshFetch is not compatible with `typeof fetch` - fetchImplementation: configContext.fetch, - + fetch: configContext.fetch, + circuitBreaker, name: 'hive-gateway', version: globalThis.__VERSION__, }); - unifiedGraphFetcher = () => - fetcher().then(({ supergraphSdl }) => supergraphSdl); + unifiedGraphFetcher = { + fetch: () => fetcher.fetch().then(({ contents }) => contents), + dispose: () => fetcher.dispose(), + }; } else if (config.supergraph.type === 'graphos') { const graphosFetcherContainer = createGraphOSFetcher({ graphosOpts: config.supergraph, configContext, pollingInterval: config.pollingInterval, }); - unifiedGraphFetcher = graphosFetcherContainer.unifiedGraphFetcher; + unifiedGraphFetcher = { + fetch: graphosFetcherContainer.unifiedGraphFetcher, + }; } else { - unifiedGraphFetcher = () => { - throw new Error( - `Unknown supergraph configuration: ${config.supergraph}`, - ); + unifiedGraphFetcher = { + fetch: () => { + throw new Error( + `Unknown supergraph configuration: ${config.supergraph}`, + ); + }, }; } } else { @@ -689,24 +736,29 @@ export function createGatewayRuntime< ); } - unifiedGraphFetcher = () => - handleUnifiedGraphConfig( - // @ts-expect-error TODO: what's up with type narrowing - config.supergraph, - configContext, - ); + unifiedGraphFetcher = { + fetch: () => + handleUnifiedGraphConfig( + // @ts-expect-error TODO: what's up with type narrowing + config.supergraph, + configContext, + ), + }; } - const instrumentedGraphFetcher = unifiedGraphFetcher; - unifiedGraphFetcher = (...args) => - getInstrumented(null).asyncFn( - instrumentation?.schema, - instrumentedGraphFetcher, - )(...args); + const instrumentedGraphFetcher = unifiedGraphFetcher.fetch; + unifiedGraphFetcher = { + ...unifiedGraphFetcher, + fetch: (...args) => + getInstrumented(null).asyncFn( + instrumentation?.schema, + instrumentedGraphFetcher, + )(...args), + }; const unifiedGraphManager = new UnifiedGraphManager({ handleUnifiedGraph: config.unifiedGraphHandler, - getUnifiedGraph: unifiedGraphFetcher, + getUnifiedGraph: unifiedGraphFetcher.fetch, onUnifiedGraphChange(newUnifiedGraph: GraphQLSchema) { unifiedGraph = newUnifiedGraph; replaceSchema(newUnifiedGraph); @@ -756,7 +808,10 @@ export function createGatewayRuntime< getExecutor = () => unifiedGraphManager.getExecutor(); unifiedGraphPlugin = { onDispose() { - return dispose(unifiedGraphManager); + return handleMaybePromise( + () => dispose(unifiedGraphManager), + () => unifiedGraphFetcher.dispose?.(), + ); }, }; } diff --git a/packages/runtime/src/getReportingPlugin.ts b/packages/runtime/src/getReportingPlugin.ts index 810b12486e..3b5eff4fab 100644 --- a/packages/runtime/src/getReportingPlugin.ts +++ b/packages/runtime/src/getReportingPlugin.ts @@ -43,6 +43,7 @@ export function getReportingPlugin>( endpoint: config.persistedDocuments.endpoint, accessToken: config.persistedDocuments.token, }, + circuitBreaker: config.persistedDocuments.circuitBreaker, // Trick to satisfy the Hive Console plugin types allowArbitraryDocuments: allowArbitraryDocuments as boolean, }, diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 901bd082a1..6c4d073300 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -1,5 +1,6 @@ import type { Plugin as EnvelopPlugin } from '@envelop/core'; import type { GenericAuthPluginOptions } from '@envelop/generic-auth'; +import { CircuitBreakerConfiguration } from '@graphql-hive/core'; import type { Logger, LogLevel } from '@graphql-hive/logger'; import type { PubSub } from '@graphql-hive/pubsub'; import type { @@ -53,6 +54,7 @@ import { UpstreamTimeoutPluginOptions } from './plugins/useUpstreamTimeout'; export type { UnifiedGraphHandler, UnifiedGraphPlugin }; export type { TransportEntryAdditions, UnifiedGraphConfig }; +export type { CircuitBreakerConfiguration }; export type GatewayConfig< TContext extends Record = Record, @@ -335,11 +337,12 @@ export interface GatewayHiveCDNOptions { /** * GraphQL Hive CDN endpoint URL. */ - endpoint: string; + endpoint: string | [string, string]; /** * GraphQL Hive CDN access key. */ key: string; + circuitBreaker?: CircuitBreakerConfiguration; } export interface GatewayHiveReportingOptions extends Omit< @@ -437,7 +440,11 @@ export interface GatewayHivePersistedDocumentsOptions { /** * GraphQL Hive persisted documents CDN endpoint URL. */ - endpoint: string; + endpoint: string | [string, string]; + /** + * Circuit Breaker configuration to customize CDN failures handling and switch to mirror endpoint. + */ + circuitBreaker?: CircuitBreakerConfiguration; /** * GraphQL Hive persisted documents CDN access token. */ diff --git a/packages/runtime/tests/__snapshots__/hive.spec.ts.snap b/packages/runtime/tests/__snapshots__/hive.spec.ts.snap index 5fa1c5ef92..a3efad793a 100644 --- a/packages/runtime/tests/__snapshots__/hive.spec.ts.snap +++ b/packages/runtime/tests/__snapshots__/hive.spec.ts.snap @@ -1,6 +1,24 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Hive CDN respects env vars: hive-cdn 1`] = ` +exports[`Hive CDN should request using "/supergraph(.graphql)" pathname from cdn even if the user provides "" as the pathname: hive-cdn 1`] = ` +"type Query { + foo: String +}" +`; + +exports[`Hive CDN should request using "/supergraph(.graphql)" pathname from cdn even if the user provides "/artifacts/v1" as the pathname: hive-cdn 1`] = ` +"type Query { + foo: String +}" +`; + +exports[`Hive CDN should request using "/supergraph(.graphql)" pathname from cdn even if the user provides "/supergraph" as the pathname: hive-cdn 1`] = ` +"type Query { + foo: String +}" +`; + +exports[`Hive CDN should request using "/supergraph(.graphql)" pathname from cdn even if the user provides "/supergraph.graphql" as the pathname: hive-cdn 1`] = ` "type Query { foo: String }" diff --git a/packages/runtime/tests/hive.spec.ts b/packages/runtime/tests/hive.spec.ts index cbda3f7caa..33b2a56649 100644 --- a/packages/runtime/tests/hive.spec.ts +++ b/packages/runtime/tests/hive.spec.ts @@ -57,11 +57,15 @@ function createUpstreamSchema() { } describe('Hive CDN', () => { - it('respects env vars', async () => { - await using cdnServer = await createDisposableServer( - createServerAdapter( - () => - new Response( + it.each(['', '/artifacts/v1', '/supergraph', '/supergraph.graphql'])( + 'should request using "/supergraph(.graphql)" pathname from cdn even if the user provides "%s" as the pathname', + async (pathname) => { + await using cdnServer = await createDisposableServer( + createServerAdapter((req) => { + if (!/\/supergraph(\.graphql)*$/.test(req.url)) { + return new Response('Huh', { status: 404 }); + } + return new Response( getUnifiedGraphGracefully([ { name: 'upstream', @@ -69,91 +73,101 @@ describe('Hive CDN', () => { url: 'http://upstream/graphql', }, ]), - ), - ), - ); - await using gateway = createGatewayRuntime({ - supergraph: { - type: 'hive', - endpoint: cdnServer.url, - key: 'key', - }, - logging: isDebug(), - }); - - const res = await gateway.fetch( - 'http://localhost/graphql', - initForExecuteFetchArgs({ - query: getIntrospectionQuery({ - descriptions: false, + ); }), - }), - ); + ); + await using gateway = createGatewayRuntime({ + supergraph: { + type: 'hive', + endpoint: cdnServer.url + pathname, + key: 'key', + }, + logging: isDebug(), + }); - expect(res.status).toBe(200); - const resJson: ExecutionResult = await res.json(); - const clientSchema = buildClientSchema(resJson.data!); - expect(printSchema(clientSchema)).toMatchSnapshot('hive-cdn'); - }); - it('uses Hive CDN instead of introspection for Proxy mode', async () => { - const upstreamSchema = createUpstreamSchema(); - await using cdnServer = await createDisposableServer( - createServerAdapter(() => - Response.json({ - sdl: printSchemaWithDirectives(upstreamSchema), + const res = await gateway.fetch( + 'http://localhost/graphql', + initForExecuteFetchArgs({ + query: getIntrospectionQuery({ + descriptions: false, + }), }), - ), - ); - await using upstreamServer = createYoga({ - schema: upstreamSchema, - // Make sure introspection is not fetched from the service itself - plugins: [useDisableIntrospection()], - }); - let schemaChangeSpy = vi.fn((_schema: GraphQLSchema) => {}); - const hiveEndpoint = cdnServer.url; - const hiveKey = 'key'; - await using gateway = createGatewayRuntime({ - proxy: { endpoint: 'http://upstream/graphql' }, - schema: { - type: 'hive', - endpoint: hiveEndpoint, - key: hiveKey, - }, - plugins: () => [ - useCustomFetch((url, opts): MaybePromise => { - if (url === 'http://upstream/graphql') { - // @ts-expect-error - Fetch signature is not compatible - return upstreamServer.fetch(url, opts); + ); + + expect(res.status).toBe(200); + const resJson: ExecutionResult = await res.json(); + const clientSchema = buildClientSchema(resJson.data!); + expect(printSchema(clientSchema)).toMatchSnapshot('hive-cdn'); + }, + ); + + it.each(['', '/artifacts/v1', '/sdl', '/services', '/sdl.graphql'])( + 'should request using "/sdl(.graphql)" pathname from cdn even if the user provides "%s" as the pathname in proxy mode', + async (pathname) => { + const upstreamSchema = createUpstreamSchema(); + await using cdnServer = await createDisposableServer( + createServerAdapter((req) => { + if ( + req.url.includes('services') || // "/services" gets replaced + !/\/sdl(\.graphql)*$/.test(req.url) + ) { + return new Response('Huh', { status: 404 }); } - return gateway.fetchAPI.Response.error(); + return new Response(printSchemaWithDirectives(upstreamSchema)); }), - { - onSchemaChange({ schema }) { - schemaChangeSpy(schema); + ); + await using upstreamServer = createYoga({ + schema: upstreamSchema, + // Make sure introspection is not fetched from the service itself + plugins: [useDisableIntrospection()], + }); + let schemaChangeSpy = vi.fn((_schema: GraphQLSchema) => {}); + const hiveEndpoint = cdnServer.url; + const hiveKey = 'key'; + await using gateway = createGatewayRuntime({ + proxy: { endpoint: 'http://upstream/graphql' }, + schema: { + type: 'hive', + endpoint: hiveEndpoint + pathname, + key: hiveKey, + }, + plugins: () => [ + useCustomFetch((url, opts): MaybePromise => { + if (url === 'http://upstream/graphql') { + // @ts-expect-error - Fetch signature is not compatible + return upstreamServer.fetch(url, opts); + } + return gateway.fetchAPI.Response.error(); + }), + { + onSchemaChange({ schema }) { + schemaChangeSpy(schema); + }, }, + ], + logging: isDebug(), + }); + + await expect( + executeFetch(gateway, { + query: /* GraphQL */ ` + query { + foo + } + `, + }), + ).resolves.toEqual({ + data: { + foo: 'bar', }, - ], - logging: isDebug(), - }); + }); + expect(schemaChangeSpy).toHaveBeenCalledTimes(1); + expect(printSchema(schemaChangeSpy.mock.calls[0]?.[0]!)).toBe( + printSchema(upstreamSchema), + ); + }, + ); - await expect( - executeFetch(gateway, { - query: /* GraphQL */ ` - query { - foo - } - `, - }), - ).resolves.toEqual({ - data: { - foo: 'bar', - }, - }); - expect(schemaChangeSpy).toHaveBeenCalledTimes(1); - expect(printSchema(schemaChangeSpy.mock.calls[0]?.[0]!)).toBe( - printSchema(upstreamSchema), - ); - }); it('handles reporting', async () => { const token = 'secret'; @@ -383,4 +397,119 @@ describe('Hive CDN', () => { await setTimeout(10); // allow hive client to flush before disposing // TODO: gateway.dispose() should be enough but it isnt, leaktests report a leak }); + + it('should handle supergraph cdn circuit breaker when first endpoint is unavailable', async () => { + const upstreamSchema = createUpstreamSchema(); + + await using upstreamServer = createYoga({ + schema: upstreamSchema, + // Make sure introspection is not fetched from the service itself + plugins: [useDisableIntrospection()], + }); + + await using cdnServer = await createDisposableServer( + createServerAdapter(() => new Response(null, { status: 504 })), + ); + + await using cdnMirrorServer = await createDisposableServer( + createServerAdapter( + () => + new Response( + getUnifiedGraphGracefully([ + { + name: 'upstream', + schema: upstreamSchema, + url: 'http://upstream/graphql', + }, + ]), + ), + ), + ); + + await using gateway = createGatewayRuntime({ + supergraph: { + type: 'hive', + endpoint: [cdnServer.url, cdnMirrorServer.url], + key: 'key', + }, + plugins: () => [ + { + onFetch({ url, setFetchFn }) { + if (url === 'http://upstream/graphql') { + // @ts-expect-error - Fetch signature is not compatible + setFetchFn(upstreamServer.fetch); + } + }, + }, + ], + }); + + const res = await gateway.fetch( + 'http://localhost/graphql', + initForExecuteFetchArgs({ + query: /* GraphQL */ ` + { + foo + } + `, + }), + ); + + expect(res.ok).toBeTruthy(); + await expect(res.json()).resolves.toMatchInlineSnapshot(` + { + "data": { + "foo": "bar", + }, + } + `); + }); + + it('should handle persisted documents cdn circuit breaker when first endpoint is unavailable', async () => { + const upstreamSchema = createUpstreamSchema(); + + await using upstreamServer = await createDisposableServer( + createYoga({ + schema: upstreamSchema, + }), + ); + + await using cdnServer = await createDisposableServer( + createServerAdapter(() => new Response(null, { status: 504 })), + ); + + await using cdnMirrorServer = await createDisposableServer( + createServerAdapter(() => { + return new Response(/* GraphQL */ ` + query MyTest { + foo + } + `); + }), + ); + + await using gateway = createGatewayRuntime({ + proxy: { + endpoint: `${upstreamServer.url}/graphql`, + }, + persistedDocuments: { + type: 'hive', + endpoint: [cdnServer.url, cdnMirrorServer.url], + token: 'token', + }, + }); + + await expect( + executeFetch(gateway, { + documentId: + 'graphql-app~1.0.0~Eaca86e9999dce9b4f14c4ed969aca3258d22ed00', + }), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "foo": "bar", + }, + } + `); + }); }); diff --git a/yarn.lock b/yarn.lock index 6649804985..c65e572444 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1008,7 +1008,7 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/types@npm:3.936.0, @aws-sdk/types@npm:^3.222.0": +"@aws-sdk/types@npm:3.936.0": version: 3.936.0 resolution: "@aws-sdk/types@npm:3.936.0" dependencies: @@ -1018,6 +1018,16 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/types@npm:^3.222.0": + version: 3.922.0 + resolution: "@aws-sdk/types@npm:3.922.0" + dependencies: + "@smithy/types": "npm:^4.8.1" + tslib: "npm:^2.6.2" + checksum: 10c0/8a02f3af191d553ed54d30c404ac35c439db71c64ed45a7bcbf53e6200662030df8f28e0559679b14aa0d0afbb91479c11cc4656545a80d0a64567e6959cfca0 + languageName: node + linkType: hard + "@aws-sdk/util-endpoints@npm:3.936.0": version: 3.936.0 resolution: "@aws-sdk/util-endpoints@npm:3.936.0" @@ -8934,6 +8944,15 @@ __metadata: languageName: node linkType: hard +"@smithy/types@npm:^4.8.1": + version: 4.8.1 + resolution: "@smithy/types@npm:4.8.1" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/517f90a32f19f867456b253d99ab9d96b680bde8dd19129e7e7cabf355e8082d8d25ece561fef68a73a1d961155dedc30d44194e25232ed05005399aa5962195 + languageName: node + linkType: hard + "@smithy/types@npm:^4.9.0": version: 4.9.0 resolution: "@smithy/types@npm:4.9.0" @@ -9700,7 +9719,16 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:24.10.1, @types/node@npm:>=13.7.0, @types/node@npm:^24.10.1": +"@types/node@npm:*, @types/node@npm:>=13.7.0": + version: 24.10.0 + resolution: "@types/node@npm:24.10.0" + dependencies: + undici-types: "npm:~7.16.0" + checksum: 10c0/f82ed7194e16f5590ef7afdc20c6d09068c76d50278b485ede8f0c5749683536e3064ffa8def8db76915196afb3724b854aa5723c64d6571b890b14492943b46 + languageName: node + linkType: hard + +"@types/node@npm:24.10.1, @types/node@npm:^24.10.1": version: 24.10.1 resolution: "@types/node@npm:24.10.1" dependencies: @@ -10383,6 +10411,28 @@ __metadata: languageName: node linkType: hard +"@whatwg-node/fetch@npm:^0.10.12": + version: 0.10.12 + resolution: "@whatwg-node/fetch@npm:0.10.12" + dependencies: + "@whatwg-node/node-fetch": "npm:^0.8.2" + urlpattern-polyfill: "npm:^10.0.0" + checksum: 10c0/f7628c719c0448bd6b2ac935a91930310251ec61e3eb1b8a97cac7994c62b35d4e0e2f014dafe11c2327fb3fe6a56635c12ca96afa5cc6b74e8c838821c09588 + languageName: node + linkType: hard + +"@whatwg-node/node-fetch@npm:^0.8.2": + version: 0.8.2 + resolution: "@whatwg-node/node-fetch@npm:0.8.2" + dependencies: + "@fastify/busboy": "npm:^3.1.1" + "@whatwg-node/disposablestack": "npm:^0.0.6" + "@whatwg-node/promise-helpers": "npm:^1.3.2" + tslib: "npm:^2.6.3" + checksum: 10c0/5768beaffe3df53260bea498d1a2fbadcd8b4bc92903e5920f23fec67f97d82f32c4b5f4effb75352d5447a8b08c440f0e16a8c9196443d9468f3f8ccf6b62b2 + languageName: node + linkType: hard + "@whatwg-node/node-fetch@npm:^0.8.3": version: 0.8.4 resolution: "@whatwg-node/node-fetch@npm:0.8.4" @@ -10415,7 +10465,20 @@ __metadata: languageName: node linkType: hard -"@whatwg-node/server@npm:^0.10.0, @whatwg-node/server@npm:^0.10.14, @whatwg-node/server@npm:^0.10.17, @whatwg-node/server@npm:^0.10.5": +"@whatwg-node/server@npm:^0.10.0, @whatwg-node/server@npm:^0.10.14, @whatwg-node/server@npm:^0.10.5": + version: 0.10.15 + resolution: "@whatwg-node/server@npm:0.10.15" + dependencies: + "@envelop/instrumentation": "npm:^1.0.0" + "@whatwg-node/disposablestack": "npm:^0.0.6" + "@whatwg-node/fetch": "npm:^0.10.12" + "@whatwg-node/promise-helpers": "npm:^1.3.2" + tslib: "npm:^2.6.3" + checksum: 10c0/6391cec0a829f436ba3d79dd761c9d473cc5b2892a6065d2e87f94aab5ffd1773b899f1d98b524f5e7ada80117ef0d7624cac9c2ee517f9261abad920528b270 + languageName: node + linkType: hard + +"@whatwg-node/server@npm:^0.10.17": version: 0.10.17 resolution: "@whatwg-node/server@npm:0.10.17" dependencies: