diff --git a/.changeset/calm-mails-sneeze.md b/.changeset/calm-mails-sneeze.md new file mode 100644 index 0000000000..838aeeee00 --- /dev/null +++ b/.changeset/calm-mails-sneeze.md @@ -0,0 +1,55 @@ +--- +'@graphql-hive/core': minor +'@graphql-hive/apollo': minor +'@graphql-hive/yoga': minor +--- + +**Persisted Documents Improvements** + +Persisted documents now support specifying a mirror endpoint that will be used in case the main CDN +is unreachable. Provide an array of endpoints to the client configuration. + +```ts +import { createClient } from '@graphql-hive/core' + +const client = createClient({ + experimental__persistedDocuments: { + cdn: { + endpoint: [ + 'https://cdn.graphql-hive.com/artifacts/v1/9fb37bc4-e520-4019-843a-0c8698c25688', + 'https://cdn-mirror.graphql-hive.com/artifacts/v1/9fb37bc4-e520-4019-843a-0c8698c25688' + ], + accessToken: '' + } + } +}) +``` + +In addition to that, the underlying logic for looking up documents now uses a circuit breaker. If a +single endpoint is unreachable, further lookups on that endpoint are skipped. + +The behaviour of the circuit breaker can be customized via the `circuitBreaker` configuration. + +```ts +import { createClient } from '@graphql-hive/core' + +const client = createClient({ + experimental__persistedDocuments: { + cdn: { + endpoint: [ + 'https://cdn.graphql-hive.com/artifacts/v1/9fb37bc4-e520-4019-843a-0c8698c25688', + 'https://cdn-mirror.graphql-hive.com/artifacts/v1/9fb37bc4-e520-4019-843a-0c8698c25688' + ], + accessToken: '' + }, + circuitBreaker: { + // open circuit if 50 percent of request result in an error + errorThresholdPercentage: 50, + // start monitoring the circuit after 10 requests + volumeThreshold: 10, + // time before the backend is tried again after the circuit is open + resetTimeout: 30_000 + } + } +}) +``` diff --git a/.changeset/nine-worlds-slide.md b/.changeset/nine-worlds-slide.md new file mode 100644 index 0000000000..98fde7a6af --- /dev/null +++ b/.changeset/nine-worlds-slide.md @@ -0,0 +1,43 @@ +--- +'@graphql-hive/apollo': minor +--- + +**Supergraph Manager Improvements** + +Persisted documents now support specifying a mirror endpoint that will be used in case the main CDN +is unreachable. Provide an array of endpoints to the supergraph manager configuration. + +```ts +import { createSupergraphManager } from '@graphql-hive/apollo' + +const supergraphManager = createSupergraphManager({ + endpoint: [ + 'https://cdn.graphql-hive.com/artifacts/v1/9fb37bc4-e520-4019-843a-0c8698c25688/supergraph', + 'https://cdn-mirror.graphql-hive.com/artifacts/v1/9fb37bc4-e520-4019-843a-0c8698c25688/supergraph' + ], + key: '' +}) +``` + +In addition to that, the underlying logic for looking up documents now uses a circuit breaker. If a +single endpoint is unreachable, further lookups on that endpoint are skipped. + +```ts +import { createSupergraphManager } from '@graphql-hive/apollo' + +const supergraphManager = createSupergraphManager({ + endpoint: [ + 'https://cdn.graphql-hive.com/artifacts/v1/9fb37bc4-e520-4019-843a-0c8698c25688/supergraph', + 'https://cdn-mirror.graphql-hive.com/artifacts/v1/9fb37bc4-e520-4019-843a-0c8698c25688/supergraph' + ], + key: '', + circuitBreaker: { + // open circuit if 50 percent of request result in an error + errorThresholdPercentage: 50, + // start monitoring the circuit after 10 requests + volumeThreshold: 10, + // time before the backend is tried again after the circuit is open + resetTimeout: 30_000 + } +}) +``` diff --git a/.changeset/upset-lemons-reply.md b/.changeset/upset-lemons-reply.md new file mode 100644 index 0000000000..260fe88eba --- /dev/null +++ b/.changeset/upset-lemons-reply.md @@ -0,0 +1,25 @@ +--- +'@graphql-hive/core': minor +--- + +**New CDN Artifact Fetcher** + +We have a new interface for fetching CDN artifacts (such as supergraph and services) with a cache +from the CDN. This fetcher supports providing a mirror endpoint and comes with a circuit breaker +under the hood. + +```ts +const supergraphFetcher = createCDNArtifactFetcher({ + endpoint: [ + 'https://cdn.graphql-hive.com/artifacts/v1/9fb37bc4-e520-4019-843a-0c8698c25688', + 'https://cdn-mirror.graphql-hive.com/artifacts/v1/9fb37bc4-e520-4019-843a-0c8698c25688' + ], + accessKey: '' +}) + +supergraphFetcher.fetch() +``` + +--- + +`createSupergraphSDLFetcher` is now deprecated. Please upgrade to use `createCDNArtifactFetcher`. diff --git a/packages/libraries/apollo/package.json b/packages/libraries/apollo/package.json index 9d45e7d456..8d046a2c3d 100644 --- a/packages/libraries/apollo/package.json +++ b/packages/libraries/apollo/package.json @@ -47,7 +47,8 @@ "graphql": "^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" }, "dependencies": { - "@graphql-hive/core": "workspace:*" + "@graphql-hive/core": "workspace:*", + "@graphql-hive/logger": "^1.0.9" }, "devDependencies": { "@apollo/server": "5.0.0", diff --git a/packages/libraries/apollo/src/index.ts b/packages/libraries/apollo/src/index.ts index 5a7c026314..d5812bc2b8 100644 --- a/packages/libraries/apollo/src/index.ts +++ b/packages/libraries/apollo/src/index.ts @@ -2,13 +2,15 @@ import { GraphQLError, type DocumentNode } from 'graphql'; import type { ApolloServerPlugin, HTTPGraphQLRequest } from '@apollo/server'; import { autoDisposeSymbol, + createCDNArtifactFetcher, createHive as createHiveClient, - createSupergraphSDLFetcher, HiveClient, HivePluginOptions, isHiveClient, - SupergraphSDLFetcherOptions, + joinUrl, + type CircuitBreakerConfiguration, } from '@graphql-hive/core'; +import { Logger } from '@graphql-hive/logger'; import { version } from './version.js'; export { @@ -17,14 +19,65 @@ export { createServicesFetcher, createSupergraphSDLFetcher, } from '@graphql-hive/core'; + +/** @deprecated Use {CreateSupergraphManagerArgs} instead */ export type { SupergraphSDLFetcherOptions } from '@graphql-hive/core'; -export function createSupergraphManager({ - pollIntervalInMs, - ...superGraphFetcherOptions -}: { pollIntervalInMs?: number } & SupergraphSDLFetcherOptions) { - pollIntervalInMs = pollIntervalInMs ?? 30_000; - const fetchSupergraph = createSupergraphSDLFetcher(superGraphFetcherOptions); +/** + * Configuration for {createSupergraphManager}. + */ +export type CreateSupergraphManagerArgs = { + /** + * The artifact endpoint to poll. + * E.g. `https://cdn.graphql-hive.com//supergraph` + */ + endpoint: string | [string, string]; + /** + * The CDN access key for fetching artifact. + */ + key: string; + logger?: Logger; + /** + * The supergraph poll interval in milliseconds + * Default: 30_000 + */ + pollIntervalInMs?: number; + /** Circuit breaker configuration override. */ + circuitBreaker?: CircuitBreakerConfiguration; + fetchImplementation?: typeof fetch; + /** + * Client name override + * Default: `@graphql-hive/apollo` + */ + name?: string; + /** + * Client version override + * Default: currents package version + */ + version?: string; +}; + +export function createSupergraphManager(args: CreateSupergraphManagerArgs) { + const logger = args.logger ?? new Logger({ level: false }); + const pollIntervalInMs = args.pollIntervalInMs ?? 30_000; + let endpoints = Array.isArray(args.endpoint) ? args.endpoint : [args.endpoint]; + + const endpoint = endpoints.map(endpoint => + endpoint.endsWith('/supergraph') ? endpoint : joinUrl(endpoint, 'supergraph'), + ); + + const artifactsFetcher = createCDNArtifactFetcher({ + endpoint: endpoint as [string, string], + accessKey: args.key, + client: { + name: args.name ?? '@graphql-hive/apollo', + version: args.version ?? version, + }, + logger, + fetch: args.fetchImplementation, + circuitBreaker: args.circuitBreaker, + }); + let timer: ReturnType | null = null; return { @@ -32,19 +85,17 @@ export function createSupergraphManager({ supergraphSdl: string; cleanup?: () => Promise; }> { - const initialResult = await fetchSupergraph(); + const initialResult = await artifactsFetcher.fetch(); function poll() { timer = setTimeout(async () => { try { - const result = await fetchSupergraph(); - if (result.supergraphSdl) { - hooks.update?.(result.supergraphSdl); + const result = await artifactsFetcher.fetch(); + if (result.contents) { + hooks.update?.(result.contents); } } catch (error) { - console.error( - `Failed to update supergraph: ${error instanceof Error ? error.message : error}`, - ); + logger.error({ error }, `Failed to update supergraph.`); } poll(); }, pollIntervalInMs); @@ -53,11 +104,12 @@ export function createSupergraphManager({ poll(); return { - supergraphSdl: initialResult.supergraphSdl, + supergraphSdl: initialResult.contents, cleanup: async () => { if (timer) { clearTimeout(timer); } + artifactsFetcher.dispose(); }, }; }, diff --git a/packages/libraries/core/src/client/agent.ts b/packages/libraries/core/src/client/agent.ts index 1d939a212e..a026255e0b 100644 --- a/packages/libraries/core/src/client/agent.ts +++ b/packages/libraries/core/src/client/agent.ts @@ -1,35 +1,15 @@ import CircuitBreaker from '../circuit-breaker/circuit.js'; import { version } from '../version.js'; +import { + CircuitBreakerConfiguration, + defaultCircuitBreakerConfiguration, +} from './circuit-breaker.js'; import { http } from './http-client.js'; import type { LegacyLogger } from './types.js'; import { chooseLogger } from './utils.js'; type ReadOnlyResponse = Pick; -export type AgentCircuitBreakerConfiguration = { - /** - * Percentage after what the circuit breaker should kick in. - * Default: 50 - */ - errorThresholdPercentage: number; - /** - * Count of requests before starting evaluating. - * Default: 5 - */ - volumeThreshold: number; - /** - * After what time the circuit breaker is attempting to retry sending requests in milliseconds - * Default: 30_000 - */ - resetTimeout: number; -}; - -const defaultCircuitBreakerConfiguration: AgentCircuitBreakerConfiguration = { - errorThresholdPercentage: 50, - volumeThreshold: 10, - resetTimeout: 30_000, -}; - export interface AgentOptions { enabled?: boolean; name?: string; @@ -80,7 +60,7 @@ export interface AgentOptions { * false -> Disable * object -> use custom configuration see {AgentCircuitBreakerConfiguration} */ - circuitBreaker?: boolean | AgentCircuitBreakerConfiguration; + circuitBreaker?: boolean | CircuitBreakerConfiguration; /** * WHATWG Compatible fetch implementation * used by the agent to send reports @@ -105,7 +85,7 @@ export function createAgent( }, ) { const options: Required> & { - circuitBreaker: AgentCircuitBreakerConfiguration | null; + circuitBreaker: CircuitBreakerConfiguration | null; } = { timeout: 30_000, enabled: true, diff --git a/packages/libraries/core/src/client/cdn-artifact-fetcher.ts b/packages/libraries/core/src/client/cdn-artifact-fetcher.ts new file mode 100644 index 0000000000..bbe2779fe2 --- /dev/null +++ b/packages/libraries/core/src/client/cdn-artifact-fetcher.ts @@ -0,0 +1,171 @@ +import { Logger } from '@graphql-hive/logger'; +import CircuitBreaker from '../circuit-breaker/circuit.js'; +import { version } from '../version.js'; +import { + CircuitBreakerConfiguration, + defaultCircuitBreakerConfiguration, +} from './circuit-breaker.js'; +import { http, HttpCallConfig } from './http-client.js'; +import { chooseLogger, createHash } from './utils.js'; + +type CreateCDNArtifactFetcherArgs = { + /** + * The endpoint that should be fetched. + * + * It is possible to provide an endpoint list. The first endpoint will be treated as the primary source. + * The secondary endpoint will be used in case the first endpoint fails to respond. + * + * Example: + * + * ``` + * [ + * "https://cdn.graphql-hive.com/artifacts/v1/9fb37bc4-e520-4019-843a-0c8698c25688/supergraph", + * "https://cdn-mirror.graphql-hive.com/artifacts/v1/9fb37bc4-e520-4019-843a-0c8698c25688/supergraph" + * ] + * ``` + */ + endpoint: string | [string, string]; + /** + * The access key that is used for authenticating on the endpoints (via the `X-Hive-CDN-Key` header). + */ + accessKey: string; + logger?: Logger; + circuitBreaker?: CircuitBreakerConfiguration; + /** + * Custom fetch implementation used for calling the endpoint. + */ + fetch?: typeof fetch; + /** Amount of retries per endpoint lookup attempt */ + retry?: HttpCallConfig['retry']; + /** Timeout per retry for endpoint lookup */ + timeout?: HttpCallConfig['timeout']; + /** + * Optional client meta configuration. + **/ + client?: { + name: string; + version: string; + }; +}; + +type CDNFetchResult = { + /** Text contents of the artifact */ + contents: string; + /** SHA-256 Hash */ + hash: string; + /** Schema Version ID as on Hive Console (optional) */ + schemaVersionId: null | string; +}; + +function isRequestOk(response: Response) { + return response.status === 304 || response.ok; +} + +export type CDNArtifactFetcher = { + /** Call the CDN and retrieve the lastest artifact version. */ + fetch(): Promise; + /** Dispose the fetcher and cleanup existing timers (e.g. used for circuit breaker) */ + dispose(): void; +}; + +/** + * Create a handler for fetching a CDN artifact with built-in cache and circuit breaker. + * It is intended for polling supergraph, schema sdl or services. + */ +export function createCDNArtifactFetcher(args: CreateCDNArtifactFetcherArgs): CDNArtifactFetcher { + const logger = chooseLogger(args.logger); + let cacheETag: string | null = null; + let cached: CDNFetchResult | null = null; + const clientInfo = args.client ?? { name: 'hive-client', version }; + const circuitBreakerConfig = args.circuitBreaker ?? defaultCircuitBreakerConfiguration; + const endpoints = Array.isArray(args.endpoint) ? args.endpoint : [args.endpoint]; + + function runFetch(circuitBreaker: CircuitBreaker, endpoint: string) { + const signal = circuitBreaker.getSignal(); + const headers: { + [key: string]: string; + } = { + 'X-Hive-CDN-Key': args.accessKey, + 'User-Agent': `${clientInfo.name}/${clientInfo.version}`, + }; + + if (cacheETag) { + headers['If-None-Match'] = cacheETag; + } + + return http.get(endpoint, { + headers, + isRequestOk, + retry: args.retry ?? { + retries: 10, + maxTimeout: 200, + minTimeout: 1, + }, + timeout: args.timeout, + logger, + fetchImplementation: args.fetch, + signal, + }); + } + + const circuitBreakers = endpoints.map(endpoint => { + const circuitBreaker = new CircuitBreaker( + async function fire() { + return await runFetch(circuitBreaker, endpoint); + }, + { + ...circuitBreakerConfig, + timeout: false, + autoRenewAbortController: true, + }, + ); + return circuitBreaker; + }); + + async function attempt(breaker: CircuitBreaker) { + const response: Response = await breaker.fire(); + + if (response.status === 304) { + if (cached !== null) { + return cached; + } + throw new Error('Unexpected 304 with no cache'); + } + + const contents = await response.text(); + const result: CDNFetchResult = { + hash: await createHash('SHA-256').update(contents).digest('base64'), + contents, + schemaVersionId: response.headers.get('x-hive-schema-version-id') ?? null, + }; + + const etag = response.headers.get('etag'); + if (etag) { + cached = result; + cacheETag = etag; + } + + return result; + } + + return { + async fetch(): Promise { + for (const [index, breaker] of circuitBreakers.entries()) { + try { + return await attempt(breaker); + } catch (error: unknown) { + logger.debug({ error }); + if (index === circuitBreakers.length - 1) { + if (cached) { + return cached; + } + } + } + } + throw new Error('Could not retrieve artifact.'); + }, + dispose() { + circuitBreakers.forEach(breaker => breaker.shutdown()); + }, + }; +} diff --git a/packages/libraries/core/src/client/circuit-breaker.ts b/packages/libraries/core/src/client/circuit-breaker.ts new file mode 100644 index 0000000000..731f6cb8fa --- /dev/null +++ b/packages/libraries/core/src/client/circuit-breaker.ts @@ -0,0 +1,23 @@ +export type CircuitBreakerConfiguration = { + /** + * Percentage after what the circuit breaker should kick in. + * Default: 50 + */ + errorThresholdPercentage: number; + /** + * Count of requests before starting evaluating. + * Default: 5 + */ + volumeThreshold: number; + /** + * After what time the circuit breaker is attempting to retry sending requests in milliseconds + * Default: 30_000 + */ + resetTimeout: number; +}; + +export const defaultCircuitBreakerConfiguration: CircuitBreakerConfiguration = { + errorThresholdPercentage: 50, + volumeThreshold: 10, + resetTimeout: 30_000, +}; diff --git a/packages/libraries/core/src/client/client.ts b/packages/libraries/core/src/client/client.ts index 143f48eed8..378a30d59b 100644 --- a/packages/libraries/core/src/client/client.ts +++ b/packages/libraries/core/src/client/client.ts @@ -48,6 +48,13 @@ export function createHive(options: HivePluginOptions): HiveClient { const usage = createUsage(mergedOptions); const schemaReporter = createReporting(mergedOptions); + const persistedDocuments = options.experimental__persistedDocuments + ? createPersistedDocuments({ + ...options.experimental__persistedDocuments, + logger, + fetch: options.experimental__persistedDocuments.fetch, + }) + : null; function reportSchema({ schema }: { schema: GraphQLSchema }) { schemaReporter.report({ schema }); @@ -62,7 +69,7 @@ export function createHive(options: HivePluginOptions): HiveClient { } async function dispose() { - await Promise.all([schemaReporter.dispose(), usage.dispose()]); + await Promise.all([schemaReporter.dispose(), usage.dispose(), persistedDocuments?.dispose()]); } const isOrganizationAccessToken = !isLegacyAccessToken(options.token ?? ''); @@ -236,13 +243,7 @@ export function createHive(options: HivePluginOptions): HiveClient { collectSubscriptionUsage: usage.collectSubscription, createInstrumentedSubscribe, createInstrumentedExecute, - experimental__persistedDocuments: options.experimental__persistedDocuments - ? createPersistedDocuments({ - ...options.experimental__persistedDocuments, - logger, - fetch: options.experimental__persistedDocuments.fetch, - }) - : null, + experimental__persistedDocuments: persistedDocuments, }; } diff --git a/packages/libraries/core/src/client/http-client.ts b/packages/libraries/core/src/client/http-client.ts index d7b037810a..b68f607956 100644 --- a/packages/libraries/core/src/client/http-client.ts +++ b/packages/libraries/core/src/client/http-client.ts @@ -4,7 +4,7 @@ import { abortSignalAny } from '@graphql-hive/signal'; import { crypto, fetch, URL } from '@whatwg-node/fetch'; import type { LegacyLogger } from './types'; -interface SharedConfig { +export interface HttpCallConfig { headers: Record; /** * timeout in milliseconds (for each single fetch call) @@ -32,9 +32,9 @@ interface SharedConfig { */ type ResponseAssertFunction = (response: Response) => boolean; -type RetryOptions = Parameters[1]; +export type RetryOptions = Parameters[1]; -function get(endpoint: string, config: SharedConfig) { +function get(endpoint: string, config: HttpCallConfig) { return makeFetchCall(endpoint, { method: 'GET', headers: config.headers, @@ -46,7 +46,7 @@ function get(endpoint: string, config: SharedConfig) { }); } -function post(endpoint: string, data: string | Buffer, config: SharedConfig) { +function post(endpoint: string, data: string | Buffer, config: HttpCallConfig) { return makeFetchCall(endpoint, { body: data, method: 'POST', @@ -59,7 +59,7 @@ export const http = { post, }; -function chooseLogger(logger: SharedConfig['logger']): Logger { +function chooseLogger(logger: HttpCallConfig['logger']): Logger { if (!logger) { return new Logger({ writers: [{ write() {} }], diff --git a/packages/libraries/core/src/client/persisted-documents.ts b/packages/libraries/core/src/client/persisted-documents.ts index 3ab53a9b74..fe00bd9a89 100644 --- a/packages/libraries/core/src/client/persisted-documents.ts +++ b/packages/libraries/core/src/client/persisted-documents.ts @@ -1,22 +1,33 @@ import type { PromiseOrValue } from 'graphql/jsutils/PromiseOrValue.js'; import LRU from 'tiny-lru'; import { Logger } from '@graphql-hive/logger'; -import { http } from './http-client.js'; +import CircuitBreaker from '../circuit-breaker/circuit.js'; +import { defaultCircuitBreakerConfiguration } from './circuit-breaker.js'; +import { http, HttpCallConfig } from './http-client.js'; import type { PersistedDocumentsConfiguration } from './types'; type HeadersObject = { get(name: string): string | null; }; +function isRequestOk(response: Response) { + return response.status === 200 || response.status === 404; +} + +type PersistedDocuments = { + resolve(documentId: string): PromiseOrValue; + allowArbitraryDocuments(context: { headers?: HeadersObject }): PromiseOrValue; + dispose: () => void; +}; + export function createPersistedDocuments( config: PersistedDocumentsConfiguration & { logger: Logger; fetch?: typeof fetch; + retry?: HttpCallConfig['retry']; + timeout?: HttpCallConfig['retry']; }, -): null | { - resolve(documentId: string): PromiseOrValue; - allowArbitraryDocuments(context: { headers?: HeadersObject }): PromiseOrValue; -} { +): PersistedDocuments { const persistedDocumentsCache = LRU(config.cache ?? 10_000); let allowArbitraryDocuments: (context: { headers?: HeadersObject }) => PromiseOrValue; @@ -33,6 +44,44 @@ export function createPersistedDocuments( /** if there is already a in-flight request for a document, we re-use it. */ const fetchCache = new Map>(); + const endpoints = Array.isArray(config.cdn.endpoint) + ? config.cdn.endpoint + : [config.cdn.endpoint]; + + const circuitBreakers = endpoints.map(endpoint => { + const circuitBreaker = new CircuitBreaker( + async function doFetch(cdnDocumentId: string) { + const signal = circuitBreaker.getSignal(); + + return await http + .get(endpoint + '/apps/' + cdnDocumentId, { + headers: { + 'X-Hive-CDN-Key': config.cdn.accessToken, + }, + logger: config.logger, + isRequestOk, + fetchImplementation: config.fetch, + signal, + retry: config.retry, + }) + .then(async response => { + if (response.status !== 200) { + return null; + } + const text = await response.text(); + return text; + }); + }, + { + ...(config.circuitBreaker ?? defaultCircuitBreakerConfiguration), + timeout: false, + autoRenewAbortController: true, + }, + ); + + return circuitBreaker; + }); + /** Batch load a persisted documents */ function loadPersistedDocument(documentId: string) { const document = persistedDocumentsCache.get(documentId); @@ -40,41 +89,48 @@ export function createPersistedDocuments( return document; } - const cdnDocumentId = documentId.replaceAll('~', '/'); - - const url = config.cdn.endpoint + '/apps/' + cdnDocumentId; - let promise = fetchCache.get(url); - - if (!promise) { - promise = http - .get(url, { - headers: { - 'X-Hive-CDN-Key': config.cdn.accessToken, - }, - logger: config.logger, - isRequestOk: response => response.status === 200 || response.status === 404, - fetchImplementation: config.fetch, - }) - .then(async response => { - if (response.status !== 200) { - return null; - } - const text = await response.text(); - persistedDocumentsCache.set(documentId, text); - return text; - }) - .finally(() => { - fetchCache.delete(url); - }); - - fetchCache.set(url, promise); + let promise = fetchCache.get(documentId); + if (promise) { + return promise; } + promise = Promise.resolve() + .then(async () => { + const cdnDocumentId = documentId.replaceAll('~', '/'); + + let lastError: unknown = null; + + for (const breaker of circuitBreakers) { + try { + return await breaker.fire(cdnDocumentId); + } catch (error: unknown) { + config.logger.debug({ error }); + lastError = error; + } + } + if (lastError) { + config.logger.error({ error: lastError }); + } + throw new Error('Failed to look up persisted operation.'); + }) + .then(result => { + persistedDocumentsCache.set(documentId, result); + return result; + }) + .finally(() => { + fetchCache.delete(documentId); + }); + + fetchCache.set(documentId, promise); + return promise; } return { allowArbitraryDocuments, resolve: loadPersistedDocument, + dispose() { + circuitBreakers.map(breaker => breaker.shutdown()); + }, }; } diff --git a/packages/libraries/core/src/client/supergraph.ts b/packages/libraries/core/src/client/supergraph.ts index d4465d3b3b..9c1bcfe74d 100644 --- a/packages/libraries/core/src/client/supergraph.ts +++ b/packages/libraries/core/src/client/supergraph.ts @@ -4,6 +4,9 @@ import { http } from './http-client.js'; import type { LegacyLogger } from './types.js'; import { createHash, joinUrl } from './utils.js'; +/** + * @deprecated Please use {createCDNArtifactFetcher} instead of createSupergraphSDLFetcher. + */ export interface SupergraphSDLFetcherOptions { endpoint: string; key: string; @@ -13,6 +16,9 @@ export interface SupergraphSDLFetcherOptions { version?: string; } +/** + * @deprecated Please use {createCDNArtifactFetcher} instead. + */ export function createSupergraphSDLFetcher(options: SupergraphSDLFetcherOptions) { let cacheETag: string | null = null; let cached: { diff --git a/packages/libraries/core/src/client/types.ts b/packages/libraries/core/src/client/types.ts index 3e4c0995d2..5ef0e06d80 100644 --- a/packages/libraries/core/src/client/types.ts +++ b/packages/libraries/core/src/client/types.ts @@ -2,6 +2,7 @@ import type { ExecutionArgs } from 'graphql'; import type { PromiseOrValue } from 'graphql/jsutils/PromiseOrValue.js'; import { LogLevel as HiveLoggerLevel, Logger } from '@graphql-hive/logger'; import type { AgentOptions } from './agent.js'; +import { CircuitBreakerConfiguration } from './circuit-breaker.js'; import type { autoDisposeSymbol, hiveClientSymbol } from './client.js'; import type { SchemaReporter } from './reporting.js'; @@ -301,10 +302,20 @@ export type PersistedDocumentsConfiguration = { **/ cdn: { /** - * CDN endpoint - * @example https://cdn.graphql-hive.com/artifacts/v1/5d80a1c2-2532-419c-8bb5-75bb04ea1112 + * CDN endpoint(s) for looking up persisted documents. + * + * It is possible to provide an endpoint list. The first endpoint will be treated as the primary source. + * The secondary endpoint will be used in case the first endpoint fails to respond. + * + * @example + * ``` + * [ + * "https://cdn.graphql-hive.com/artifacts/v1/9fb37bc4-e520-4019-843a-0c8698c25688", + * "https://cdn-mirror.graphql-hive.com/artifacts/v1/9fb37bc4-e520-4019-843a-0c8698c25688" + * ] + * ``` */ - endpoint: string; + endpoint: string | [string, string]; /** * CDN access token * @example hv2ZjUxNGUzN2MtNjVhNS0= @@ -327,6 +338,8 @@ export type PersistedDocumentsConfiguration = { * used for doing HTTP requests. */ fetch?: typeof fetch; + /** Configuration for the circuit breaker. */ + circuitBreaker?: CircuitBreakerConfiguration; }; export type AllowArbitraryDocumentsFunction = (context: { diff --git a/packages/libraries/core/src/index.ts b/packages/libraries/core/src/index.ts index f908d25959..d4cb1b2368 100644 --- a/packages/libraries/core/src/index.ts +++ b/packages/libraries/core/src/index.ts @@ -13,3 +13,8 @@ export { isHiveClient, isAsyncIterable, createHash, joinUrl } from './client/uti export { http, URL } from './client/http-client.js'; export { createSupergraphSDLFetcher } from './client/supergraph.js'; export type { SupergraphSDLFetcherOptions } from './client/supergraph.js'; +export { + createCDNArtifactFetcher, + type CDNArtifactFetcher, +} from './client/cdn-artifact-fetcher.js'; +export type { CircuitBreakerConfiguration } from './client/circuit-breaker.js'; diff --git a/packages/libraries/core/tests/cdn-artifact-fetcher.spec.ts b/packages/libraries/core/tests/cdn-artifact-fetcher.spec.ts new file mode 100644 index 0000000000..16233fda14 --- /dev/null +++ b/packages/libraries/core/tests/cdn-artifact-fetcher.spec.ts @@ -0,0 +1,79 @@ +import { createCDNArtifactFetcher } from '../src'; + +test('can fetch artifact', async () => { + const calls: Array = []; + const fetcher = createCDNArtifactFetcher({ + endpoint: 'https://cdn.localhost/artifacts/v1/target/sdl', + accessKey: 'foobars', + async fetch(input) { + calls.push(input); + return new Response('type Query { hello: String }', { + headers: { + 'x-hive-schema-version-id': '69', + }, + }); + }, + }); + + const result = await fetcher.fetch(); + expect(result).toMatchInlineSnapshot(` + { + contents: type Query { hello: String }, + hash: lPMnu/9YxAJHyFqOBpHcrya5Bihef1wDGz7iKcif5nY=, + schemaVersionId: 69, + } + `); + expect(calls).toMatchInlineSnapshot(` + [ + https://cdn.localhost/artifacts/v1/target/sdl, + ] + `); +}); + +test('calls mirror if main source is not working', async () => { + const calls: Array = []; + const fetcher = createCDNArtifactFetcher({ + endpoint: [ + 'https://cdn.localhost/artifacts/v1/target/sdl', + 'https://cdn-mirror.localhost/artifacts/v1/target/sdl', + ], + accessKey: 'foobars', + retry: false, + circuitBreaker: { + volumeThreshold: 1, + errorThresholdPercentage: 1, + resetTimeout: 30_000, + }, + async fetch(input) { + calls.push(input); + + if (calls.length === 1) { + throw new Error('Network error or something.'); + } + if (calls.length === 2) { + return new Response('type Query { hello: String }', { + headers: { + 'x-hive-schema-version-id': '69', + }, + }); + } + + throw new Error('This should not happen'); + }, + }); + + const result = await fetcher.fetch(); + expect(result).toMatchInlineSnapshot(` + { + contents: type Query { hello: String }, + hash: lPMnu/9YxAJHyFqOBpHcrya5Bihef1wDGz7iKcif5nY=, + schemaVersionId: 69, + } + `); + expect(calls).toMatchInlineSnapshot(` + [ + https://cdn.localhost/artifacts/v1/target/sdl, + https://cdn-mirror.localhost/artifacts/v1/target/sdl, + ] + `); +}); diff --git a/packages/libraries/core/tests/persisted-documents.spec.ts b/packages/libraries/core/tests/persisted-documents.spec.ts new file mode 100644 index 0000000000..3347c6662d --- /dev/null +++ b/packages/libraries/core/tests/persisted-documents.spec.ts @@ -0,0 +1,138 @@ +import { Logger, MemoryLogWriter } from '@graphql-hive/logger'; +import { createPersistedDocuments } from '../src/client/persisted-documents'; + +test('calls mirror if main source is not working', async () => { + const logger = new Logger({ level: false }); + + const calls: Array = []; + + const persistedDocuments = createPersistedDocuments({ + cdn: { + endpoint: [ + 'https://cdn.localhost/artifacts/v1/target', + 'https://cdn-mirror.localhost/artifacts/v1/target', + ], + accessToken: 'foobars', + }, + logger, + async fetch(args) { + calls.push(args); + + if (calls.length === 1) { + throw new Error('Network error or something.'); + } + if (calls.length === 2) { + return new Response('{helloWorld}'); + } + + throw new Error('This should not happen'); + }, + retry: false, + timeout: false, + }); + + const result = await persistedDocuments.resolve('graphql-hive/v0.0.0/sha512:123'); + expect(result).toEqual('{helloWorld}'); + expect(calls).toMatchInlineSnapshot(` + [ + https://cdn.localhost/artifacts/v1/target/apps/graphql-hive/v0.0.0/sha512:123, + https://cdn-mirror.localhost/artifacts/v1/target/apps/graphql-hive/v0.0.0/sha512:123, + ] + `); +}); + +test('does not use main source for repeated lookups', async () => { + const logger = new Logger({ level: false }); + + const calls: Array = []; + + const persistedDocuments = createPersistedDocuments({ + cdn: { + endpoint: [ + 'https://cdn.localhost/artifacts/v1/target', + 'https://cdn-mirror.localhost/artifacts/v1/target', + ], + accessToken: 'foobars', + }, + logger, + async fetch(args) { + calls.push(args); + + if (calls.length === 1) { + throw new Error('Network error or something.'); + } + if (calls.length === 2) { + return new Response('{helloWorld}'); + } + if (calls.length === 3) { + return new Response('{foobars}'); + } + + throw new Error('This should not happen'); + }, + retry: false, + timeout: false, + circuitBreaker: { + errorThresholdPercentage: 1, + volumeThreshold: 1, + resetTimeout: 30_000, + }, + }); + + const result1 = await persistedDocuments.resolve('graphql-hive/v0.0.0/sha512:123'); + expect(result1).toEqual('{helloWorld}'); + const result2 = await persistedDocuments.resolve('graphql-hive/v0.0.0/sha512:456'); + expect(result2).toEqual('{foobars}'); + expect(calls).toMatchInlineSnapshot(` + [ + https://cdn.localhost/artifacts/v1/target/apps/graphql-hive/v0.0.0/sha512:123, + https://cdn-mirror.localhost/artifacts/v1/target/apps/graphql-hive/v0.0.0/sha512:123, + https://cdn-mirror.localhost/artifacts/v1/target/apps/graphql-hive/v0.0.0/sha512:456, + ] + `); +}); + +test('fails fast if circuit breaker kicks in', async () => { + const logWriter = new MemoryLogWriter(); + const logger = new Logger({ level: 'debug', writers: [logWriter] }); + + const calls: Array = []; + + const persistedDocuments = createPersistedDocuments({ + cdn: { + endpoint: 'https://cdn.localhost/artifacts/v1/target', + accessToken: 'foobars', + }, + logger, + async fetch(args) { + calls.push(args); + + if (calls.length === 1) { + throw new Error('Network error or something.'); + } + + throw new Error('This should not happen'); + }, + retry: false, + timeout: false, + circuitBreaker: { + errorThresholdPercentage: 1, + volumeThreshold: 1, + resetTimeout: 30_000, + }, + }); + + await expect( + persistedDocuments.resolve('graphql-hive/v0.0.0/sha512:123'), + ).to.rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Failed to look up persisted operation.]`, + ); + + await expect( + persistedDocuments.resolve('graphql-hive/v0.0.0/sha512:123'), + ).to.rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Failed to look up persisted operation.]`, + ); + + expect((logWriter.logs.pop()?.attrs as any).error?.code).toEqual('EOPENBREAKER'); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4442c98307..6fe117f0f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -396,6 +396,9 @@ importers: '@graphql-hive/core': specifier: workspace:* version: link:../core/dist + '@graphql-hive/logger': + specifier: ^1.0.9 + version: 1.0.9 devDependencies: '@apollo/server': specifier: 5.0.0