From 5dd9752a56836f82a7a8f6530652dbf1aa6eb05f Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Tue, 18 Nov 2025 15:35:53 +0100 Subject: [PATCH 01/13] feat: circuit breaker for supergraph fetcher --- packages/libraries/apollo/src/index.ts | 72 +++++++-- .../libraries/core/src/client/artifacts.ts | 144 ++++++++++++++++++ .../libraries/core/src/client/supergraph.ts | 6 + packages/libraries/core/src/index.ts | 4 + 4 files changed, 215 insertions(+), 11 deletions(-) create mode 100644 packages/libraries/core/src/client/artifacts.ts diff --git a/packages/libraries/apollo/src/index.ts b/packages/libraries/apollo/src/index.ts index 5a7c0263143..e0002703e3d 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, + CDNArtifactFetcherCircuitBreakerConfiguration, + createCDNArtifactFetcher, createHive as createHiveClient, - createSupergraphSDLFetcher, HiveClient, HivePluginOptions, isHiveClient, - SupergraphSDLFetcherOptions, + joinUrl, + Logger, } 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/artifacts.ts b/packages/libraries/core/src/client/artifacts.ts new file mode 100644 index 00000000000..05111526715 --- /dev/null +++ b/packages/libraries/core/src/client/artifacts.ts @@ -0,0 +1,144 @@ +import CircuitBreaker from '../circuit-breaker/circuit.js'; +import { version } from '../version.js'; +import { http } from './http-client.js'; +import type { Logger } from './types.js'; +import { createHash, createHiveLogger } from './utils.js'; + +export type CDNArtifactFetcherCircuitBreakerConfiguration = { + /** + * 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: CDNArtifactFetcherCircuitBreakerConfiguration = { + errorThresholdPercentage: 50, + volumeThreshold: 10, + resetTimeout: 30_000, +}; + +type CreateCDNArtifactFetcherArgs = { + endpoint: string; + accessKey: string; + /** client meta data */ + client?: { + name: string; + version: string; + }; + circuitBreaker?: CDNArtifactFetcherCircuitBreakerConfiguration; + 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/supergraph.ts b/packages/libraries/core/src/client/supergraph.ts index d4465d3b3b3..9c1bcfe74d7 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/index.ts b/packages/libraries/core/src/index.ts index f908d25959b..e89f51ec9b9 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'; From 85267438ad552b46a5354fa6951a3a0089d5c689 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Tue, 18 Nov 2025 16:22:42 +0100 Subject: [PATCH 02/13] persisted documents as well? --- packages/libraries/apollo/src/index.ts | 2 +- packages/libraries/core/src/client/agent.ts | 32 ++-------- .../libraries/core/src/client/artifacts.ts | 30 ++------- .../core/src/client/persisted-documents.ts | 61 +++++++++++++------ packages/libraries/core/src/client/types.ts | 2 + 5 files changed, 56 insertions(+), 71 deletions(-) diff --git a/packages/libraries/apollo/src/index.ts b/packages/libraries/apollo/src/index.ts index e0002703e3d..d96435dc824 100644 --- a/packages/libraries/apollo/src/index.ts +++ b/packages/libraries/apollo/src/index.ts @@ -2,7 +2,6 @@ import { GraphQLError, type DocumentNode } from 'graphql'; import type { ApolloServerPlugin, HTTPGraphQLRequest } from '@apollo/server'; import { autoDisposeSymbol, - CDNArtifactFetcherCircuitBreakerConfiguration, createCDNArtifactFetcher, createHive as createHiveClient, HiveClient, @@ -10,6 +9,7 @@ import { isHiveClient, joinUrl, Logger, + type CDNArtifactFetcherCircuitBreakerConfiguration, } from '@graphql-hive/core'; import { version } from './version.js'; diff --git a/packages/libraries/core/src/client/agent.ts b/packages/libraries/core/src/client/agent.ts index 1d939a212ec..a026255e0bb 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/artifacts.ts b/packages/libraries/core/src/client/artifacts.ts index 05111526715..97e7f5003ea 100644 --- a/packages/libraries/core/src/client/artifacts.ts +++ b/packages/libraries/core/src/client/artifacts.ts @@ -1,33 +1,13 @@ 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'; -export type CDNArtifactFetcherCircuitBreakerConfiguration = { - /** - * 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: CDNArtifactFetcherCircuitBreakerConfiguration = { - errorThresholdPercentage: 50, - volumeThreshold: 10, - resetTimeout: 30_000, -}; - type CreateCDNArtifactFetcherArgs = { endpoint: string; accessKey: string; @@ -36,7 +16,7 @@ type CreateCDNArtifactFetcherArgs = { name: string; version: string; }; - circuitBreaker?: CDNArtifactFetcherCircuitBreakerConfiguration; + circuitBreaker?: CircuitBreakerConfiguration; logger?: Logger; fetch?: typeof fetch; }; diff --git a/packages/libraries/core/src/client/persisted-documents.ts b/packages/libraries/core/src/client/persisted-documents.ts index 3ab53a9b743..9cc86ce4881 100644 --- a/packages/libraries/core/src/client/persisted-documents.ts +++ b/packages/libraries/core/src/client/persisted-documents.ts @@ -1,6 +1,8 @@ import type { PromiseOrValue } from 'graphql/jsutils/PromiseOrValue.js'; import LRU from 'tiny-lru'; import { Logger } from '@graphql-hive/logger'; +import CircuitBreaker from '../circuit-breaker/circuit.js'; +import { defaultCircuitBreakerConfiguration } from './circuit-breaker.js'; import { http } from './http-client.js'; import type { PersistedDocumentsConfiguration } from './types'; @@ -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: Logger; @@ -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/types.ts b/packages/libraries/core/src/client/types.ts index 3e4c0995d29..904febe656b 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'; @@ -327,6 +328,7 @@ export type PersistedDocumentsConfiguration = { * used for doing HTTP requests. */ fetch?: typeof fetch; + circuitBreaker?: CircuitBreakerConfiguration; }; export type AllowArbitraryDocumentsFunction = (context: { From 058efa0a6f9649452162691d1de656c681b2d83b Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 19 Nov 2025 00:36:01 +0100 Subject: [PATCH 03/13] missing --- .../core/src/client/circuit-breaker.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 packages/libraries/core/src/client/circuit-breaker.ts 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 00000000000..731f6cb8fa5 --- /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, +}; From ffb8fca1c38c32cf69fb44d55a4045d0eb8d258e Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 24 Nov 2025 12:16:59 +0100 Subject: [PATCH 04/13] support multiple endpoints --- .../libraries/core/src/client/artifacts.ts | 130 +++++++++++------- packages/libraries/core/src/index.ts | 6 +- 2 files changed, 86 insertions(+), 50 deletions(-) diff --git a/packages/libraries/core/src/client/artifacts.ts b/packages/libraries/core/src/client/artifacts.ts index 97e7f5003ea..4ae397da851 100644 --- a/packages/libraries/core/src/client/artifacts.ts +++ b/packages/libraries/core/src/client/artifacts.ts @@ -9,21 +9,39 @@ import type { Logger } from './types.js'; import { createHash, createHiveLogger } from './utils.js'; type CreateCDNArtifactFetcherArgs = { - endpoint: string; + /** + * 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; - /** client meta data */ + logger?: Logger; + circuitBreaker?: CircuitBreakerConfiguration; + /** + * Custom fetch implementation used for calling the endpoint. + */ + fetch?: typeof fetch; + /** + * Optional client meta configuration. + **/ client?: { name: string; version: string; }; - circuitBreaker?: CircuitBreakerConfiguration; - logger?: Logger; - fetch?: typeof fetch; -}; - -type CDNFetcherArgs = { - logger?: Logger; - fetch?: typeof fetch; }; type CDNFetchResult = { @@ -48,47 +66,67 @@ export function createCDNArtifactFetcher(args: CreateCDNArtifactFetcherArgs) { let cached: CDNFetchResult | null = null; const clientInfo = args.client ?? { name: 'hive-client', version }; const circuitBreakerConfig = args.circuitBreaker ?? defaultCircuitBreakerConfiguration; + const logger = createHiveLogger(args.logger ?? console, ''); - 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}`, - }; + const endpoints = Array.isArray(args.endpoint) ? args.endpoint : [args.endpoint]; + + // TODO: we should probably do some endpoint validation + // And print some errors if the enpoint paths do not match? + // e.g. the only difference should be the domain name + + const circuitBreakers = endpoints.map(endpoint => { + const circuitBreaker = new CircuitBreaker( + function runFetch() { + 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: { + retries: 10, + maxTimeout: 200, + minTimeout: 1, + }, + logger, + fetchImplementation: args.fetch, + signal, + }); + }, + { + ...circuitBreakerConfig, + timeout: false, + autoRenewAbortController: true, + }, + ); + return circuitBreaker; + }); + + return async function fetchArtifact(): Promise { + // TODO: we can probably do that better... + // If an items is half open, we would probably want to try both the opened and half opened one + const fetcher = circuitBreakers.find(item => item.opened || item.halfOpen); - if (cacheETag) { - headers['If-None-Match'] = cacheETag; + if (!fetcher) { + if (cached !== null) { + return cached; } - 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 { + throw new Error('Failed to retrieve artifact.'); + } + try { - const response = await circuitBreaker.fire(fetchArgs); + const response = await fetcher.fire(); if (response.status === 304) { if (cached !== null) { diff --git a/packages/libraries/core/src/index.ts b/packages/libraries/core/src/index.ts index e89f51ec9b9..23120ba70b5 100644 --- a/packages/libraries/core/src/index.ts +++ b/packages/libraries/core/src/index.ts @@ -13,7 +13,5 @@ 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'; +export { createCDNArtifactFetcher } from './client/artifacts.js'; +export type { CircuitBreakerConfiguration } from './client/circuit-breaker.js'; From 8d39a7b320a7efc6d8326523fc26601708033aad Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 26 Nov 2025 13:31:08 +0100 Subject: [PATCH 05/13] improve circuit breaker api for artifacts fetcher --- packages/libraries/apollo/package.json | 1 + packages/libraries/apollo/src/index.ts | 13 +- .../libraries/core/src/client/artifacts.ts | 120 +++++++++++------- packages/libraries/core/src/index.ts | 2 +- pnpm-lock.yaml | 23 ++-- 5 files changed, 96 insertions(+), 63 deletions(-) diff --git a/packages/libraries/apollo/package.json b/packages/libraries/apollo/package.json index 9d45e7d4564..55c830cb11a 100644 --- a/packages/libraries/apollo/package.json +++ b/packages/libraries/apollo/package.json @@ -52,6 +52,7 @@ "devDependencies": { "@apollo/server": "5.0.0", "@as-integrations/express4": "1.1.2", + "@graphql-hive/logger": "^1.0.9", "@graphql-tools/schema": "10.0.25", "@types/express": "4.17.21", "body-parser": "1.20.3", diff --git a/packages/libraries/apollo/src/index.ts b/packages/libraries/apollo/src/index.ts index d96435dc824..c964f3d1b96 100644 --- a/packages/libraries/apollo/src/index.ts +++ b/packages/libraries/apollo/src/index.ts @@ -8,9 +8,9 @@ import { HivePluginOptions, isHiveClient, joinUrl, - Logger, - type CDNArtifactFetcherCircuitBreakerConfiguration, + type CircuitBreakerConfiguration, } from '@graphql-hive/core'; +import { Logger } from '@graphql-hive/logger'; import { version } from './version.js'; export { @@ -43,7 +43,7 @@ export type CreateSupergraphManagerArgs = { */ pollIntervalInMs?: number; /** Circuit breaker configuration override. */ - circuitBreaker?: CDNArtifactFetcherCircuitBreakerConfiguration; + circuitBreaker?: CircuitBreakerConfiguration; fetchImplementation?: typeof fetch; /** * Client name override @@ -58,6 +58,7 @@ export type CreateSupergraphManagerArgs = { }; export function createSupergraphManager(args: CreateSupergraphManagerArgs) { + const logger = args.logger ?? new Logger({ level: false }); const pollIntervalInMs = args.pollIntervalInMs ?? 30_000; const endpoint = args.endpoint.endsWith('/supergraph') ? args.endpoint @@ -70,7 +71,7 @@ export function createSupergraphManager(args: CreateSupergraphManagerArgs) { name: args.name ?? '@graphql-hive/apollo', version: args.version ?? version, }, - logger: args.logger, + logger, fetch: args.fetchImplementation, circuitBreaker: args.circuitBreaker, }); @@ -92,9 +93,7 @@ export function createSupergraphManager(args: CreateSupergraphManagerArgs) { 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); diff --git a/packages/libraries/core/src/client/artifacts.ts b/packages/libraries/core/src/client/artifacts.ts index 4ae397da851..3f948436edf 100644 --- a/packages/libraries/core/src/client/artifacts.ts +++ b/packages/libraries/core/src/client/artifacts.ts @@ -1,3 +1,4 @@ +import { Logger } from '@graphql-hive/logger'; import CircuitBreaker from '../circuit-breaker/circuit.js'; import { version } from '../version.js'; import { @@ -5,8 +6,7 @@ import { defaultCircuitBreakerConfiguration, } from './circuit-breaker.js'; import { http } from './http-client.js'; -import type { Logger } from './types.js'; -import { createHash, createHiveLogger } from './utils.js'; +import { chooseLogger, createHash } from './utils.js'; type CreateCDNArtifactFetcherArgs = { /** @@ -57,22 +57,53 @@ function isRequestOk(response: Response) { return response.status === 304 || response.ok; } +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) { +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 logger = createHiveLogger(args.logger ?? console, ''); - const endpoints = Array.isArray(args.endpoint) ? args.endpoint : [args.endpoint]; - // TODO: we should probably do some endpoint validation - // And print some errors if the enpoint paths do not match? - // e.g. the only difference should be the domain name + function createFetch(endpoint: string) { + return function runFetch(circuitBreaker: CircuitBreaker) { + 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: { + retries: 10, + maxTimeout: 200, + minTimeout: 1, + }, + logger, + fetchImplementation: args.fetch, + signal, + }); + }; + } const circuitBreakers = endpoints.map(endpoint => { const circuitBreaker = new CircuitBreaker( @@ -112,51 +143,50 @@ export function createCDNArtifactFetcher(args: CreateCDNArtifactFetcherArgs) { return circuitBreaker; }); - return async function fetchArtifact(): Promise { - // TODO: we can probably do that better... - // If an items is half open, we would probably want to try both the opened and half opened one - const fetcher = circuitBreakers.find(item => item.opened || item.halfOpen); + async function attempt(breaker: CircuitBreaker) { + const response: Response = await breaker.fire(); - if (!fetcher) { + if (response.status === 304) { if (cached !== null) { return cached; } - - throw new Error('Failed to retrieve artifact.'); + throw new Error('Unexpected 304 with no cache'); } - try { - const response = await fetcher.fire(); - - 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; - } + 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; + 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 err; - } + throw new Error('Could not retrieve artifact.'); + }, + dispose() { + circuitBreakers.forEach(breaker => breaker.shutdown()); + }, }; } diff --git a/packages/libraries/core/src/index.ts b/packages/libraries/core/src/index.ts index 23120ba70b5..c9fede44d6e 100644 --- a/packages/libraries/core/src/index.ts +++ b/packages/libraries/core/src/index.ts @@ -13,5 +13,5 @@ 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 } from './client/artifacts.js'; +export { createCDNArtifactFetcher, type CDNArtifactFetcher } from './client/artifacts.js'; export type { CircuitBreakerConfiguration } from './client/circuit-breaker.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4442c983071..c9089dbc68c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -403,6 +403,9 @@ importers: '@as-integrations/express4': specifier: 1.1.2 version: 1.1.2(@apollo/server@5.0.0(graphql@16.9.0))(express@4.21.2) + '@graphql-hive/logger': + specifier: ^1.0.9 + version: 1.0.9 '@graphql-tools/schema': specifier: 10.0.25 version: 10.0.25(graphql@16.9.0) @@ -19398,8 +19401,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -19551,11 +19554,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0': + '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -19594,6 +19597,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -19813,11 +19817,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)': + '@aws-sdk/client-sts@3.596.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -19856,7 +19860,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -20088,7 +20091,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.596.0 '@aws-sdk/credential-provider-process': 3.587.0 @@ -20335,7 +20338,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -20724,7 +20727,7 @@ snapshots: '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 From 38f4f6902b65b4a4da177b312ae99d968472b594 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 26 Nov 2025 13:59:15 +0100 Subject: [PATCH 06/13] mirror fallback with circuit breaker? --- packages/libraries/apollo/src/index.ts | 7 +- .../libraries/core/src/client/artifacts.ts | 78 ++++-------- packages/libraries/core/src/client/client.ts | 17 +-- .../core/src/client/persisted-documents.ts | 118 +++++++++++------- packages/libraries/core/src/client/types.ts | 2 +- 5 files changed, 111 insertions(+), 111 deletions(-) diff --git a/packages/libraries/apollo/src/index.ts b/packages/libraries/apollo/src/index.ts index c964f3d1b96..e74cf4b95f6 100644 --- a/packages/libraries/apollo/src/index.ts +++ b/packages/libraries/apollo/src/index.ts @@ -64,7 +64,7 @@ export function createSupergraphManager(args: CreateSupergraphManagerArgs) { ? args.endpoint : joinUrl(args.endpoint, 'supergraph'); - const fetchSupergraph = createCDNArtifactFetcher({ + const artifactsFetcher = createCDNArtifactFetcher({ endpoint, accessKey: args.key, client: { @@ -83,12 +83,12 @@ export function createSupergraphManager(args: CreateSupergraphManagerArgs) { supergraphSdl: string; cleanup?: () => Promise; }> { - const initialResult = await fetchSupergraph(); + const initialResult = await artifactsFetcher.fetch(); function poll() { timer = setTimeout(async () => { try { - const result = await fetchSupergraph(); + const result = await artifactsFetcher.fetch(); if (result.contents) { hooks.update?.(result.contents); } @@ -107,6 +107,7 @@ export function createSupergraphManager(args: CreateSupergraphManagerArgs) { if (timer) { clearTimeout(timer); } + artifactsFetcher.dispose(); }, }; }, diff --git a/packages/libraries/core/src/client/artifacts.ts b/packages/libraries/core/src/client/artifacts.ts index 3f948436edf..7d532699dd6 100644 --- a/packages/libraries/core/src/client/artifacts.ts +++ b/packages/libraries/core/src/client/artifacts.ts @@ -57,7 +57,7 @@ function isRequestOk(response: Response) { return response.status === 304 || response.ok; } -type CDNArtifactFetcher = { +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) */ @@ -76,63 +76,37 @@ export function createCDNArtifactFetcher(args: CreateCDNArtifactFetcherArgs): CD const circuitBreakerConfig = args.circuitBreaker ?? defaultCircuitBreakerConfiguration; const endpoints = Array.isArray(args.endpoint) ? args.endpoint : [args.endpoint]; - function createFetch(endpoint: string) { - return function runFetch(circuitBreaker: CircuitBreaker) { - const signal = circuitBreaker.getSignal(); - const headers: { - [key: string]: string; - } = { - 'X-Hive-CDN-Key': args.accessKey, - 'User-Agent': `${clientInfo.name}/${clientInfo.version}`, - }; + 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; - } + if (cacheETag) { + headers['If-None-Match'] = cacheETag; + } - return http.get(endpoint, { - headers, - isRequestOk, - retry: { - retries: 10, - maxTimeout: 200, - minTimeout: 1, - }, - logger, - fetchImplementation: args.fetch, - signal, - }); - }; + return http.get(endpoint, { + headers, + isRequestOk, + retry: { + retries: 10, + maxTimeout: 200, + minTimeout: 1, + }, + logger, + fetchImplementation: args.fetch, + signal, + }); } const circuitBreakers = endpoints.map(endpoint => { const circuitBreaker = new CircuitBreaker( - function runFetch() { - 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: { - retries: 10, - maxTimeout: 200, - minTimeout: 1, - }, - logger, - fetchImplementation: args.fetch, - signal, - }); + async function fire() { + return await runFetch(circuitBreaker, endpoint); }, { ...circuitBreakerConfig, diff --git a/packages/libraries/core/src/client/client.ts b/packages/libraries/core/src/client/client.ts index 143f48eed81..378a30d59bd 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/persisted-documents.ts b/packages/libraries/core/src/client/persisted-documents.ts index 9cc86ce4881..b94da7840c2 100644 --- a/packages/libraries/core/src/client/persisted-documents.ts +++ b/packages/libraries/core/src/client/persisted-documents.ts @@ -14,15 +14,18 @@ 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; }, -): null | { - resolve(documentId: string): PromiseOrValue; - allowArbitraryDocuments(context: { headers?: HeadersObject }): PromiseOrValue; -} { +): PersistedDocuments { const persistedDocumentsCache = LRU(config.cache ?? 10_000); let allowArbitraryDocuments: (context: { headers?: HeadersObject }) => PromiseOrValue; @@ -39,41 +42,42 @@ export function createPersistedDocuments( /** if there is already a in-flight request for a document, we re-use it. */ const fetchCache = new Map>(); - const circuitBreaker = new CircuitBreaker( - async function doFetch(args: { url: string; documentId: string }) { - const signal = circuitBreaker.getSignal(); - - const promise = http - .get(args.url, { - headers: { - 'X-Hive-CDN-Key': config.cdn.accessToken, - }, - logger: config.logger, - isRequestOk, - fetchImplementation: config.fetch, - signal, - }) - .then(async response => { - if (response.status !== 200) { - return null; - } - const text = await response.text(); - persistedDocumentsCache.set(args.documentId, text); - return text; - }) - .finally(() => { - fetchCache.delete(args.url); - }); - fetchCache.set(args.url, promise); - - return await promise; - }, - { - ...(config.circuitBreaker ?? defaultCircuitBreakerConfiguration), - timeout: false, - autoRenewAbortController: true, - }, - ); + 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, + }) + .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) { @@ -82,22 +86,42 @@ export function createPersistedDocuments( return document; } - const cdnDocumentId = documentId.replaceAll('~', '/'); - - const url = config.cdn.endpoint + '/apps/' + cdnDocumentId; - const promise = fetchCache.get(url); + let promise = fetchCache.get(documentId); if (promise) { return promise; } - return circuitBreaker.fire({ - url, - documentId, - }); + promise = Promise.resolve() + .then(async () => { + const cdnDocumentId = documentId.replaceAll('~', '/'); + + for (const breaker of circuitBreakers) { + try { + return await breaker.fire(cdnDocumentId); + } catch (error: unknown) { + config.logger.debug({ error }); + } + } + throw new Error('Failed to look up artifact.'); + }) + .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/types.ts b/packages/libraries/core/src/client/types.ts index 904febe656b..617fa544b0c 100644 --- a/packages/libraries/core/src/client/types.ts +++ b/packages/libraries/core/src/client/types.ts @@ -305,7 +305,7 @@ export type PersistedDocumentsConfiguration = { * CDN endpoint * @example https://cdn.graphql-hive.com/artifacts/v1/5d80a1c2-2532-419c-8bb5-75bb04ea1112 */ - endpoint: string; + endpoint: string | [string, string]; /** * CDN access token * @example hv2ZjUxNGUzN2MtNjVhNS0= From c7e7a9bb61546299abbda26dbbd5ecbb3a0eefa8 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 26 Nov 2025 14:02:42 +0100 Subject: [PATCH 07/13] a bit of docs --- packages/libraries/core/src/client/types.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/libraries/core/src/client/types.ts b/packages/libraries/core/src/client/types.ts index 617fa544b0c..5ef0e06d800 100644 --- a/packages/libraries/core/src/client/types.ts +++ b/packages/libraries/core/src/client/types.ts @@ -302,8 +302,18 @@ 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 | [string, string]; /** @@ -328,6 +338,7 @@ export type PersistedDocumentsConfiguration = { * used for doing HTTP requests. */ fetch?: typeof fetch; + /** Configuration for the circuit breaker. */ circuitBreaker?: CircuitBreakerConfiguration; }; From eec790184dc9dd3eeb69833329b62e03c53b02ef Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 26 Nov 2025 14:28:26 +0100 Subject: [PATCH 08/13] persisted document tests --- packages/libraries/core/src/client/http-client.ts | 10 +++++----- .../core/src/client/persisted-documents.ts | 13 +++++++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/libraries/core/src/client/http-client.ts b/packages/libraries/core/src/client/http-client.ts index d7b037810a7..b68f6079562 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 b94da7840c2..fe00bd9a89e 100644 --- a/packages/libraries/core/src/client/persisted-documents.ts +++ b/packages/libraries/core/src/client/persisted-documents.ts @@ -3,7 +3,7 @@ import LRU from 'tiny-lru'; import { Logger } from '@graphql-hive/logger'; import CircuitBreaker from '../circuit-breaker/circuit.js'; import { defaultCircuitBreakerConfiguration } from './circuit-breaker.js'; -import { http } from './http-client.js'; +import { http, HttpCallConfig } from './http-client.js'; import type { PersistedDocumentsConfiguration } from './types'; type HeadersObject = { @@ -24,6 +24,8 @@ export function createPersistedDocuments( config: PersistedDocumentsConfiguration & { logger: Logger; fetch?: typeof fetch; + retry?: HttpCallConfig['retry']; + timeout?: HttpCallConfig['retry']; }, ): PersistedDocuments { const persistedDocumentsCache = LRU(config.cache ?? 10_000); @@ -60,6 +62,7 @@ export function createPersistedDocuments( isRequestOk, fetchImplementation: config.fetch, signal, + retry: config.retry, }) .then(async response => { if (response.status !== 200) { @@ -95,14 +98,20 @@ export function createPersistedDocuments( .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; } } - throw new Error('Failed to look up artifact.'); + if (lastError) { + config.logger.error({ error: lastError }); + } + throw new Error('Failed to look up persisted operation.'); }) .then(result => { persistedDocumentsCache.set(documentId, result); From 138723ce57a927f98364ecea7e70598dfc73f5df Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 26 Nov 2025 14:50:25 +0100 Subject: [PATCH 09/13] cdn artifact fetcher tests --- .../{artifacts.ts => cdn-artifact-fetcher.ts} | 9 +- packages/libraries/core/src/index.ts | 5 +- .../core/tests/cdn-artifact-fetcher.spec.ts | 79 ++++++++++ .../core/tests/persisted-documents.spec.ts | 138 ++++++++++++++++++ 4 files changed, 228 insertions(+), 3 deletions(-) rename packages/libraries/core/src/client/{artifacts.ts => cdn-artifact-fetcher.ts} (94%) create mode 100644 packages/libraries/core/tests/cdn-artifact-fetcher.spec.ts create mode 100644 packages/libraries/core/tests/persisted-documents.spec.ts diff --git a/packages/libraries/core/src/client/artifacts.ts b/packages/libraries/core/src/client/cdn-artifact-fetcher.ts similarity index 94% rename from packages/libraries/core/src/client/artifacts.ts rename to packages/libraries/core/src/client/cdn-artifact-fetcher.ts index 7d532699dd6..bbe2779fe26 100644 --- a/packages/libraries/core/src/client/artifacts.ts +++ b/packages/libraries/core/src/client/cdn-artifact-fetcher.ts @@ -5,7 +5,7 @@ import { CircuitBreakerConfiguration, defaultCircuitBreakerConfiguration, } from './circuit-breaker.js'; -import { http } from './http-client.js'; +import { http, HttpCallConfig } from './http-client.js'; import { chooseLogger, createHash } from './utils.js'; type CreateCDNArtifactFetcherArgs = { @@ -35,6 +35,10 @@ type CreateCDNArtifactFetcherArgs = { * 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. **/ @@ -92,11 +96,12 @@ export function createCDNArtifactFetcher(args: CreateCDNArtifactFetcherArgs): CD return http.get(endpoint, { headers, isRequestOk, - retry: { + retry: args.retry ?? { retries: 10, maxTimeout: 200, minTimeout: 1, }, + timeout: args.timeout, logger, fetchImplementation: args.fetch, signal, diff --git a/packages/libraries/core/src/index.ts b/packages/libraries/core/src/index.ts index c9fede44d6e..d4cb1b23685 100644 --- a/packages/libraries/core/src/index.ts +++ b/packages/libraries/core/src/index.ts @@ -13,5 +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/artifacts.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 00000000000..16233fda14f --- /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 00000000000..3347c6662de --- /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'); +}); From c8171422b106d511c10ae61633db62f03f477a73 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 26 Nov 2025 15:12:25 +0100 Subject: [PATCH 10/13] oops --- packages/libraries/apollo/src/index.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/libraries/apollo/src/index.ts b/packages/libraries/apollo/src/index.ts index e74cf4b95f6..d5812bc2b83 100644 --- a/packages/libraries/apollo/src/index.ts +++ b/packages/libraries/apollo/src/index.ts @@ -31,7 +31,7 @@ export type CreateSupergraphManagerArgs = { * The artifact endpoint to poll. * E.g. `https://cdn.graphql-hive.com//supergraph` */ - endpoint: string; + endpoint: string | [string, string]; /** * The CDN access key for fetching artifact. */ @@ -60,12 +60,14 @@ export type CreateSupergraphManagerArgs = { export function createSupergraphManager(args: CreateSupergraphManagerArgs) { const logger = args.logger ?? new Logger({ level: false }); const pollIntervalInMs = args.pollIntervalInMs ?? 30_000; - const endpoint = args.endpoint.endsWith('/supergraph') - ? args.endpoint - : joinUrl(args.endpoint, 'supergraph'); + 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: endpoint as [string, string], accessKey: args.key, client: { name: args.name ?? '@graphql-hive/apollo', From 28b880aa909f950310a1123a454cf1398d1808ee Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 26 Nov 2025 15:12:38 +0100 Subject: [PATCH 11/13] changeset --- .changeset/calm-mails-sneeze.md | 57 ++++++++++++++++++++++++++++++++ .changeset/nine-worlds-slide.md | 43 ++++++++++++++++++++++++ .changeset/upset-lemons-reply.md | 25 ++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 .changeset/calm-mails-sneeze.md create mode 100644 .changeset/nine-worlds-slide.md create mode 100644 .changeset/upset-lemons-reply.md diff --git a/.changeset/calm-mails-sneeze.md b/.changeset/calm-mails-sneeze.md new file mode 100644 index 00000000000..2ac4a2a631b --- /dev/null +++ b/.changeset/calm-mails-sneeze.md @@ -0,0 +1,57 @@ +--- +'@graphql-hive/core': minor +'@graphql-hive/apollo': 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 00000000000..98fde7a6af6 --- /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 00000000000..260fe88ebaf --- /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`. From 1f6daa2a1ebcb3adf6cc1d31acdd6be9f8932263 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 26 Nov 2025 15:18:48 +0100 Subject: [PATCH 12/13] ooops deps --- packages/libraries/apollo/package.json | 4 ++-- pnpm-lock.yaml | 26 +++++++++++++------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/libraries/apollo/package.json b/packages/libraries/apollo/package.json index 55c830cb11a..8d046a2c3d2 100644 --- a/packages/libraries/apollo/package.json +++ b/packages/libraries/apollo/package.json @@ -47,12 +47,12 @@ "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", "@as-integrations/express4": "1.1.2", - "@graphql-hive/logger": "^1.0.9", "@graphql-tools/schema": "10.0.25", "@types/express": "4.17.21", "body-parser": "1.20.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9089dbc68c..6fe117f0f53 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 @@ -403,9 +406,6 @@ importers: '@as-integrations/express4': specifier: 1.1.2 version: 1.1.2(@apollo/server@5.0.0(graphql@16.9.0))(express@4.21.2) - '@graphql-hive/logger': - specifier: ^1.0.9 - version: 1.0.9 '@graphql-tools/schema': specifier: 10.0.25 version: 10.0.25(graphql@16.9.0) @@ -19401,8 +19401,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -19554,11 +19554,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': + '@aws-sdk/client-sso-oidc@3.596.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -19597,7 +19597,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -19817,11 +19816,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0': + '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -19860,6 +19859,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -20091,7 +20091,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.596.0 '@aws-sdk/credential-provider-process': 3.587.0 @@ -20338,7 +20338,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -20727,7 +20727,7 @@ snapshots: '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 From d265dc6d6d73bf62ec597dbe41409f466b9d9874 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 26 Nov 2025 15:23:40 +0100 Subject: [PATCH 13/13] fix changeset --- .changeset/calm-mails-sneeze.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/.changeset/calm-mails-sneeze.md b/.changeset/calm-mails-sneeze.md index 2ac4a2a631b..838aeeee000 100644 --- a/.changeset/calm-mails-sneeze.md +++ b/.changeset/calm-mails-sneeze.md @@ -1,9 +1,7 @@ --- '@graphql-hive/core': minor '@graphql-hive/apollo': minor -'@graphql-hive/apollo': minor '@graphql-hive/yoga': minor - --- **Persisted Documents Improvements**