diff --git a/packages/libraries/apollo/src/index.ts b/packages/libraries/apollo/src/index.ts index 5a7c026314..d96435dc82 100644 --- a/packages/libraries/apollo/src/index.ts +++ b/packages/libraries/apollo/src/index.ts @@ -2,12 +2,14 @@ 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, + Logger, + type CDNArtifactFetcherCircuitBreakerConfiguration, } from '@graphql-hive/core'; import { version } from './version.js'; @@ -17,14 +19,62 @@ 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; + /** + * 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?: CDNArtifactFetcherCircuitBreakerConfiguration; + 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 pollIntervalInMs = args.pollIntervalInMs ?? 30_000; + const endpoint = args.endpoint.endsWith('/supergraph') + ? args.endpoint + : joinUrl(args.endpoint, 'supergraph'); + + const fetchSupergraph = createCDNArtifactFetcher({ + endpoint, + accessKey: args.key, + client: { + name: args.name ?? '@graphql-hive/apollo', + version: args.version ?? version, + }, + logger: args.logger, + fetch: args.fetchImplementation, + circuitBreaker: args.circuitBreaker, + }); + let timer: ReturnType | null = null; return { @@ -38,8 +88,8 @@ export function createSupergraphManager({ timer = setTimeout(async () => { try { const result = await fetchSupergraph(); - if (result.supergraphSdl) { - hooks.update?.(result.supergraphSdl); + if (result.contents) { + hooks.update?.(result.contents); } } catch (error) { console.error( @@ -53,7 +103,7 @@ export function createSupergraphManager({ poll(); return { - supergraphSdl: initialResult.supergraphSdl, + supergraphSdl: initialResult.contents, cleanup: async () => { if (timer) { clearTimeout(timer); diff --git a/packages/libraries/core/src/client/agent.ts b/packages/libraries/core/src/client/agent.ts index d310a0cb94..dcba00bcc0 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 { Logger } from './types.js'; import { createHiveLogger } 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; @@ -76,7 +56,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 @@ -101,7 +81,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/artifacts.ts b/packages/libraries/core/src/client/artifacts.ts new file mode 100644 index 0000000000..97e7f5003e --- /dev/null +++ b/packages/libraries/core/src/client/artifacts.ts @@ -0,0 +1,124 @@ +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 { Logger } from './types.js'; +import { createHash, createHiveLogger } from './utils.js'; + +type CreateCDNArtifactFetcherArgs = { + endpoint: string; + accessKey: string; + /** client meta data */ + client?: { + name: string; + version: string; + }; + circuitBreaker?: CircuitBreakerConfiguration; + logger?: Logger; + fetch?: typeof fetch; +}; + +type CDNFetcherArgs = { + logger?: Logger; + fetch?: typeof fetch; +}; + +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; +} + +/** + * 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) { + let cacheETag: string | null = null; + let cached: CDNFetchResult | null = null; + const clientInfo = args.client ?? { name: 'hive-client', version }; + const circuitBreakerConfig = args.circuitBreaker ?? defaultCircuitBreakerConfiguration; + + const circuitBreaker = new CircuitBreaker( + function runFetch(fetchArgs?: CDNFetcherArgs) { + const signal = circuitBreaker.getSignal(); + const logger = createHiveLogger(fetchArgs?.logger ?? args.logger ?? console, ''); + const fetchImplementation = fetchArgs?.fetch ?? args.fetch; + + 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(args.endpoint, { + headers, + isRequestOk, + retry: { + retries: 10, + maxTimeout: 200, + minTimeout: 1, + }, + logger, + fetchImplementation, + signal, + }); + }, + { + ...circuitBreakerConfig, + timeout: false, + autoRenewAbortController: true, + }, + ); + + return async function fetchArtifact(fetchArgs?: CDNFetcherArgs): Promise { + try { + const response = await circuitBreaker.fire(fetchArgs); + + if (response.status === 304) { + if (cached !== null) { + return cached; + } + throw new Error('This should never happen.'); + } + + 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; + } catch (err) { + if (err instanceof Error && 'code' in err && err.code === 'EOPENBREAKER') { + if (cached) { + return cached; + } + } + + throw err; + } + }; +} 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/persisted-documents.ts b/packages/libraries/core/src/client/persisted-documents.ts index 90a3287e43..92d1c4bbb1 100644 --- a/packages/libraries/core/src/client/persisted-documents.ts +++ b/packages/libraries/core/src/client/persisted-documents.ts @@ -1,5 +1,7 @@ import type { PromiseOrValue } from 'graphql/jsutils/PromiseOrValue.js'; import LRU from 'tiny-lru'; +import CircuitBreaker from '../circuit-breaker/circuit.js'; +import { defaultCircuitBreakerConfiguration } from './circuit-breaker.js'; import { http } from './http-client.js'; import type { PersistedDocumentsConfiguration } from './types'; import type { HiveLogger } from './utils.js'; @@ -8,6 +10,10 @@ type HeadersObject = { get(name: string): string | null; }; +function isRequestOk(response: Response) { + return response.status === 200 || response.status === 404; +} + export function createPersistedDocuments( config: PersistedDocumentsConfiguration & { logger: HiveLogger; @@ -33,44 +39,61 @@ export function createPersistedDocuments( /** if there is already a in-flight request for a document, we re-use it. */ const fetchCache = new Map>(); - /** Batch load a persisted documents */ - function loadPersistedDocument(documentId: string) { - const document = persistedDocumentsCache.get(documentId); - if (document) { - return document; - } - - const cdnDocumentId = documentId.replaceAll('~', '/'); - - const url = config.cdn.endpoint + '/apps/' + cdnDocumentId; - let promise = fetchCache.get(url); + const circuitBreaker = new CircuitBreaker( + async function doFetch(args: { url: string; documentId: string }) { + const signal = circuitBreaker.getSignal(); - if (!promise) { - promise = http - .get(url, { + const promise = http + .get(args.url, { headers: { 'X-Hive-CDN-Key': config.cdn.accessToken, }, logger: config.logger, - isRequestOk: response => response.status === 200 || response.status === 404, + isRequestOk, fetchImplementation: config.fetch, + signal, }) .then(async response => { if (response.status !== 200) { return null; } const text = await response.text(); - persistedDocumentsCache.set(documentId, text); + persistedDocumentsCache.set(args.documentId, text); return text; }) .finally(() => { - fetchCache.delete(url); + fetchCache.delete(args.url); }); + fetchCache.set(args.url, promise); - fetchCache.set(url, promise); + return await promise; + }, + { + ...(config.circuitBreaker ?? defaultCircuitBreakerConfiguration), + timeout: false, + autoRenewAbortController: true, + }, + ); + + /** Batch load a persisted documents */ + function loadPersistedDocument(documentId: string) { + const document = persistedDocumentsCache.get(documentId); + if (document) { + return document; + } + + const cdnDocumentId = documentId.replaceAll('~', '/'); + + const url = config.cdn.endpoint + '/apps/' + cdnDocumentId; + const promise = fetchCache.get(url); + if (promise) { + return promise; } - return promise; + return circuitBreaker.fire({ + url, + documentId, + }); } return { diff --git a/packages/libraries/core/src/client/supergraph.ts b/packages/libraries/core/src/client/supergraph.ts index c2a0e395ea..aba0ea7e0a 100644 --- a/packages/libraries/core/src/client/supergraph.ts +++ b/packages/libraries/core/src/client/supergraph.ts @@ -3,6 +3,9 @@ import { http } from './http-client.js'; import type { Logger } from './types.js'; import { createHash, joinUrl } from './utils.js'; +/** + * @deprecated Please use {createCDNArtifactFetcher} instead of createSupergraphSDLFetcher. + */ export interface SupergraphSDLFetcherOptions { endpoint: string; key: string; @@ -12,6 +15,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 85374d3cfb..4591377e3a 100644 --- a/packages/libraries/core/src/client/types.ts +++ b/packages/libraries/core/src/client/types.ts @@ -1,6 +1,7 @@ import type { ExecutionArgs } from 'graphql'; import type { PromiseOrValue } from 'graphql/jsutils/PromiseOrValue.js'; 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'; import { HiveLogger } from './utils.js'; @@ -318,6 +319,7 @@ export type PersistedDocumentsConfiguration = { * used for doing HTTP requests. */ fetch?: typeof fetch; + circuitBreaker?: CircuitBreakerConfiguration; }; export type AllowArbitraryDocumentsFunction = (context: { diff --git a/packages/libraries/core/src/index.ts b/packages/libraries/core/src/index.ts index 988c6c2cc9..e3b256ad52 100644 --- a/packages/libraries/core/src/index.ts +++ b/packages/libraries/core/src/index.ts @@ -13,3 +13,7 @@ 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 CDNArtifactFetcherCircuitBreakerConfiguration, +} from './client/artifacts.js';