diff --git a/.changeset/beige-teams-spend.md b/.changeset/beige-teams-spend.md deleted file mode 100644 index 5dab7888bdf..00000000000 --- a/.changeset/beige-teams-spend.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@graphql-hive/envelop': minor -'@graphql-hive/apollo': minor -'@graphql-hive/core': minor -'@graphql-hive/yoga': minor ---- - -Support circuit breaking for usage reporting. - -Circuit breaking is a fault-tolerance pattern that prevents a system from repeatedly calling a failing service. When errors or timeouts exceed a set threshold, the circuit “opens,” blocking further requests until the service recovers. - -This ensures that during a network issue or outage, the service using the Hive SDK remains healthy and is not overwhelmed by failed usage reports or repeated retries. - -```ts -import { createClient } from "@graphql-hive/core" - -const client = createClient({ - agent: { - circuitBreaker: { - /** - * Count of requests before starting evaluating. - * Default: 5 - */ - volumeThreshold: 5, - /** - * Percentage of requests failing before the circuit breaker kicks in. - * Default: 50 - */ - errorThresholdPercentage: 1, - /** - * After what time the circuit breaker is attempting to retry sending requests in milliseconds - * Default: 30_000 - */ - resetTimeout: 10_000, - }, - } -}) -``` diff --git a/packages/libraries/core/package.json b/packages/libraries/core/package.json index 70c0029dd80..2215e094b91 100644 --- a/packages/libraries/core/package.json +++ b/packages/libraries/core/package.json @@ -45,13 +45,11 @@ "graphql": "^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" }, "dependencies": { - "@graphql-hive/signal": "^2.0.0", "@graphql-tools/utils": "^10.0.0", "@whatwg-node/fetch": "^0.10.6", "async-retry": "^1.3.3", "js-md5": "0.8.3", "lodash.sortby": "^4.7.0", - "opossum": "^9.0.0", "tiny-lru": "^8.0.2" }, "devDependencies": { @@ -60,7 +58,6 @@ "@types/async-retry": "1.4.8", "@types/js-md5": "0.8.0", "@types/lodash.sortby": "4.7.9", - "@types/opossum": "8.1.9", "graphql": "16.9.0", "nock": "14.0.10", "tslib": "2.8.1", diff --git a/packages/libraries/core/playground/agent-circuit-breaker.ts b/packages/libraries/core/playground/agent-circuit-breaker.ts deleted file mode 100644 index c2661a82326..00000000000 --- a/packages/libraries/core/playground/agent-circuit-breaker.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * - * Just a small playground to play around with different scenarios arounf the agent. - * You can run it like this: `bun run --watch packages/libraries/core/playground/agent-circuit-breaker.ts` - */ - -import { createAgent } from '../src/client/agent.js'; - -let data: Array<{}> = []; - -const agent = createAgent<{}>( - { - debug: true, - endpoint: 'http://127.0.0.1', - token: 'noop', - async fetch(_url, _opts) { - // throw new Error('FAIL FAIL'); - console.log('SENDING!'); - return new Response('ok', { - status: 200, - }); - }, - circuitBreaker: { - errorThresholdPercentage: 1, - resetTimeout: 10_000, - volumeThreshold: 0, - }, - maxSize: 1, - maxRetries: 0, - }, - { - body() { - data = []; - return String(data); - }, - data: { - clear() { - data = []; - }, - size() { - return data.length; - }, - set(d) { - data.push(d); - }, - }, - }, -); - -setInterval(() => { - agent.capture({}); -}, 1_000); diff --git a/packages/libraries/core/src/client/agent.ts b/packages/libraries/core/src/client/agent.ts index ba9525d651e..1bbb6830745 100644 --- a/packages/libraries/core/src/client/agent.ts +++ b/packages/libraries/core/src/client/agent.ts @@ -1,35 +1,10 @@ -import { fetch as defaultFetch } from '@whatwg-node/fetch'; import { version } from '../version.js'; import { http } from './http-client.js'; import type { Logger } from './types.js'; -import { CircuitBreakerInterface, createHiveLogger, loadCircuitBreaker } from './utils.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; @@ -74,14 +49,7 @@ export interface AgentOptions { * WHATWG Compatible fetch implementation * used by the agent to send reports */ - fetch?: typeof defaultFetch; - /** - * Circuit Breaker Configuration. - * true -> Use default configuration - * false -> Disable - * object -> use custom configuration see {AgentCircuitBreakerConfiguration} - */ - circuitBreaker?: boolean | AgentCircuitBreakerConfiguration; + fetch?: typeof fetch; } export function createAgent( @@ -100,9 +68,7 @@ export function createAgent( headers?(): Record; }, ) { - const options: Required> & { - circuitBreaker: null | AgentCircuitBreakerConfiguration; - } = { + const options: Required> = { timeout: 30_000, enabled: true, minTimeout: 200, @@ -112,18 +78,9 @@ export function createAgent( name: 'hive-client', version, ...pluginOptions, - circuitBreaker: - pluginOptions.circuitBreaker == null || pluginOptions.circuitBreaker === true - ? defaultCircuitBreakerConfiguration - : pluginOptions.circuitBreaker === false - ? null - : pluginOptions.circuitBreaker, }; - - const logger = createHiveLogger(pluginOptions.logger ?? console, '[agent]'); - + const logger = createHiveLogger(pluginOptions.logger ?? console, '[agent]', pluginOptions.debug); const enabled = options.enabled !== false; - let timeoutID: ReturnType | null = null; function schedule() { @@ -174,27 +131,6 @@ export function createAgent( return send({ throwOnError: true, skipSchedule: true }); } - async function sendHTTPCall(buffer: string | Buffer): Promise { - const signal = breaker.getSignal(); - return await http.post(options.endpoint, buffer, { - headers: { - accept: 'application/json', - 'content-type': 'application/json', - Authorization: `Bearer ${options.token}`, - 'User-Agent': `${options.name}/${options.version}`, - ...headers(), - }, - timeout: options.timeout, - retry: { - retries: options.maxRetries, - factor: 2, - }, - logger, - fetchImplementation: pluginOptions.fetch, - signal, - }); - } - async function send(sendOptions?: { throwOnError?: boolean; skipSchedule: boolean; @@ -212,7 +148,23 @@ export function createAgent( data.clear(); logger.debug(`Sending report (queue ${dataToSend})`); - const response = sendFromBreaker(buffer) + const response = await http + .post(options.endpoint, buffer, { + headers: { + accept: 'application/json', + 'content-type': 'application/json', + Authorization: `Bearer ${options.token}`, + 'User-Agent': `${options.name}/${options.version}`, + ...headers(), + }, + timeout: options.timeout, + retry: { + retries: options.maxRetries, + factor: 2, + }, + logger, + fetchImplementation: pluginOptions.fetch, + }) .then(res => { logger.debug(`Report sent!`); return res; @@ -251,74 +203,6 @@ export function createAgent( }); } - let breaker: CircuitBreakerInterface< - Parameters, - ReturnType - >; - let loadCircuitBreakerPromise: Promise | null = null; - const breakerLogger = createHiveLogger(logger, '[circuit breaker]'); - - function noopBreaker(): typeof breaker { - return { - getSignal() { - return undefined; - }, - fire: sendHTTPCall, - }; - } - - if (options.circuitBreaker) { - /** - * We support Cloudflare, which does not has the `events` module. - * So we lazy load opossum which has `events` as a dependency. - */ - breakerLogger.info('initialize circuit breaker'); - loadCircuitBreakerPromise = loadCircuitBreaker( - CircuitBreaker => { - breakerLogger.info('started'); - const realBreaker = new CircuitBreaker(sendHTTPCall, { - ...options.circuitBreaker, - timeout: false, - autoRenewAbortController: true, - }); - - realBreaker.on('open', () => - breakerLogger.error('circuit opened - backend seems unreachable.'), - ); - realBreaker.on('halfOpen', () => - breakerLogger.info('circuit half open - testing backend connectivity'), - ); - realBreaker.on('close', () => breakerLogger.info('circuit closed - backend recovered ')); - - // @ts-expect-error missing definition in typedefs for `opposum` - breaker = realBreaker; - }, - () => { - breakerLogger.info('circuit breaker not supported on platform'); - breaker = noopBreaker(); - }, - ); - } else { - breaker = noopBreaker(); - } - - async function sendFromBreaker(...args: Parameters) { - if (!breaker) { - await loadCircuitBreakerPromise; - } - - try { - return await breaker.fire(...args); - } catch (err: unknown) { - if (err instanceof Error && 'code' in err && err.code === 'EOPENBREAKER') { - breakerLogger.info('circuit open - sending report skipped'); - return null; - } - - throw err; - } - } - return { capture, sendImmediately, diff --git a/packages/libraries/core/src/client/http-client.ts b/packages/libraries/core/src/client/http-client.ts index 7512c7deee9..139b397a10a 100644 --- a/packages/libraries/core/src/client/http-client.ts +++ b/packages/libraries/core/src/client/http-client.ts @@ -1,5 +1,4 @@ import asyncRetry from 'async-retry'; -import { abortSignalAny } from '@graphql-hive/signal'; import { crypto, fetch, URL } from '@whatwg-node/fetch'; import { Logger } from './types'; @@ -22,8 +21,6 @@ interface SharedConfig { * @default {response => response.ok} **/ isRequestOk?: ResponseAssertFunction; - /** Optional abort signal */ - signal?: AbortSignal; } /** @@ -81,8 +78,6 @@ export async function makeFetchCall( * @default {response => response.ok} **/ isRequestOk?: ResponseAssertFunction; - /** Optional abort signal */ - signal?: AbortSignal; }, ): Promise { const logger = config.logger; @@ -92,9 +87,6 @@ export async function makeFetchCall( let maxTimeout = 2000; let factor = 1.2; - const actionHeader = - config.method === 'POST' ? { 'x-client-action-id': crypto.randomUUID() } : undefined; - if (config.retry !== false) { retries = config.retry?.retries ?? 5; minTimeout = config.retry?.minTimeout ?? 200; @@ -113,15 +105,13 @@ export async function makeFetchCall( ); const getDuration = measureTime(); - const timeoutSignal = AbortSignal.timeout(config.timeout ?? 20_000); - const signal = config.signal ? abortSignalAny([config.signal, timeoutSignal]) : timeoutSignal; + const signal = AbortSignal.timeout(config.timeout ?? 20_000); const response = await (config.fetchImplementation ?? fetch)(endpoint, { method: config.method, body: config.body, headers: { 'x-request-id': requestId, - ...actionHeader, ...config.headers, }, signal, @@ -156,12 +146,6 @@ export async function makeFetchCall( throw new Error(`Unexpected HTTP error. (x-request-id=${requestId})`, { cause: error }); }); - if (config.signal?.aborted === true) { - const error = config.signal.reason ?? new Error('Request aborted externally.'); - bail(error); - throw error; - } - if (isRequestOk(response)) { logger?.debug?.( `${config.method} ${endpoint} (x-request-id=${requestId}) succeeded with status ${response.status} ${getDuration()}.`, diff --git a/packages/libraries/core/src/client/utils.ts b/packages/libraries/core/src/client/utils.ts index 7b29df14a4a..3ee19bbcf1a 100644 --- a/packages/libraries/core/src/client/utils.ts +++ b/packages/libraries/core/src/client/utils.ts @@ -1,4 +1,3 @@ -import type CircuitBreaker from 'opossum'; import { crypto, TextEncoder } from '@whatwg-node/fetch'; import { hiveClientSymbol } from './client.js'; import type { HiveClient, HivePluginOptions, Logger } from './types.js'; @@ -250,21 +249,3 @@ export function isLegacyAccessToken(accessToken: string): boolean { return false; } - -export async function loadCircuitBreaker( - success: (breaker: typeof CircuitBreaker) => void, - error: () => void, -): Promise { - const packageName = 'opossum'; - try { - const module = await import(packageName); - success(module.default); - } catch (err) { - error(); - } -} - -export type CircuitBreakerInterface = { - fire(...args: TI): TR; - getSignal(): AbortSignal | undefined; -}; diff --git a/packages/libraries/core/tests/test-utils.ts b/packages/libraries/core/tests/test-utils.ts index 31401afc788..d69eb0797ee 100644 --- a/packages/libraries/core/tests/test-utils.ts +++ b/packages/libraries/core/tests/test-utils.ts @@ -6,30 +6,24 @@ export function waitFor(ms: number) { /** helper function to get log lines and replace milliseconds with static value. */ function getLogLines(calls: Array>) { - return calls - .map(log => { - let msg: string; - if (typeof log[1] === 'string') { - if (log[1].includes('[circuit breaker]')) { - return null; - } + return calls.map(log => { + let msg: string; + if (typeof log[1] === 'string') { + msg = maskRequestId( + log[1] + // Replace milliseconds with static value + .replace(/\(\d{1,4}ms\)/, '(666ms)') + // Replace stack trace line numbers with static value + .replace(/\(node:net:\d+:\d+\)/, '(node:net:666:666)') + .replace(/\(node:dns:\d+:\d+\)/, '(node:dns:666:666)'), + // request UUIDsu + ); + } else { + msg = String(log[1]); + } - msg = maskRequestId( - log[1] - // Replace milliseconds with static value - .replace(/\(\d{1,4}ms\)/, '(666ms)') - // Replace stack trace line numbers with static value - .replace(/\(node:net:\d+:\d+\)/, '(node:net:666:666)') - .replace(/\(node:dns:\d+:\d+\)/, '(node:dns:666:666)'), - // request UUIDsu - ); - } else { - msg = String(log[1]); - } - - return '[' + log[0] + ']' + ' ' + msg; - }) - .filter(line => !!line); + return '[' + log[0] + ']' + ' ' + msg; + }); } export function createHiveTestingLogger() { diff --git a/packages/libraries/core/tsconfig.json b/packages/libraries/core/tsconfig.json index 8cc32d09e09..31d8323b929 100644 --- a/packages/libraries/core/tsconfig.json +++ b/packages/libraries/core/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../../tsconfig.json", "include": ["src"], "compilerOptions": { + "types": ["node"], "baseUrl": ".", "outDir": "dist", "rootDir": "src", diff --git a/packages/libraries/envelop/tsconfig.json b/packages/libraries/envelop/tsconfig.json index 8cc32d09e09..31d8323b929 100644 --- a/packages/libraries/envelop/tsconfig.json +++ b/packages/libraries/envelop/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../../tsconfig.json", "include": ["src"], "compilerOptions": { + "types": ["node"], "baseUrl": ".", "outDir": "dist", "rootDir": "src", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59cd995af91..4d16ec940b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -507,9 +507,6 @@ importers: packages/libraries/core: dependencies: - '@graphql-hive/signal': - specifier: ^2.0.0 - version: 2.0.0 '@graphql-tools/utils': specifier: ^10.0.0 version: 10.5.6(graphql@16.9.0) @@ -525,9 +522,6 @@ importers: lodash.sortby: specifier: ^4.7.0 version: 4.7.0 - opossum: - specifier: ^9.0.0 - version: 9.0.0 tiny-lru: specifier: ^8.0.2 version: 8.0.2 @@ -547,9 +541,6 @@ importers: '@types/lodash.sortby': specifier: 4.7.9 version: 4.7.9 - '@types/opossum': - specifier: 8.1.9 - version: 8.1.9 graphql: specifier: 16.9.0 version: 16.9.0 @@ -1448,7 +1439,7 @@ importers: devDependencies: '@graphql-inspector/core': specifier: 6.4.1 - version: 6.4.1(graphql@16.9.0) + version: 6.4.1(graphql@16.11.0) '@hive/service-common': specifier: workspace:* version: link:../service-common @@ -3807,6 +3798,7 @@ packages: '@fastify/vite@6.0.7': resolution: {integrity: sha512-+dRo9KUkvmbqdmBskG02SwigWl06Mwkw8SBDK1zTNH6vd4DyXbRvI7RmJEmBkLouSU81KTzy1+OzwHSffqSD6w==} + bundledDependencies: [] '@floating-ui/core@1.2.6': resolution: {integrity: sha512-EvYTiXet5XqweYGClEmpu3BoxmsQ4hkj3QaYA6qEnigCWffTP3vNRwBReTdrwDwo7OoJ3wM8Uoe9Uk4n+d4hfg==} @@ -3991,10 +3983,6 @@ packages: resolution: {integrity: sha512-RiwLMc89lTjvyLEivZ/qxAC5nBHoS2CtsWFSOsN35sxG9zoo5Z+JsFHM8MlvmO9yt+MJNIyC5MLE1rsbOphlag==} engines: {node: '>=18.0.0'} - '@graphql-hive/signal@2.0.0': - resolution: {integrity: sha512-Pz8wB3K0iU6ae9S1fWfsmJX24CcGeTo6hE7T44ucmV/ALKRj+bxClmqrYcDT7v3f0d12Rh4FAXBb6gon+WkDpQ==} - engines: {node: '>=20.0.0'} - '@graphql-inspector/audit-command@4.0.3': resolution: {integrity: sha512-cm4EtieIp9PUSDBze+Sn5HHF80jDF9V7sYyXqFa7+Vtw4Jlet98Ig48dFVtoLuFCPtCv2eZ22I8JOkBKL5WgVA==} engines: {node: '>=16.0.0'} @@ -8920,9 +8908,6 @@ packages: '@types/object-hash@3.0.6': resolution: {integrity: sha512-fOBV8C1FIu2ELinoILQ+ApxcUKz4ngq+IWUYrxSGjXzzjUALijilampwkMgEtJ+h2njAW3pi853QpzNVCHB73w==} - '@types/opossum@8.1.9': - resolution: {integrity: sha512-Jm/tYxuJFefiwRYs+/EOsUP3ktk0c8siMgAHPLnA4PXF4wKghzcjqf88dY+Xii5jId5Txw4JV0FMKTpjbd7KJA==} - '@types/oracledb@6.5.2': resolution: {integrity: sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==} @@ -14292,10 +14277,6 @@ packages: peerDependencies: '@opentelemetry/api': ^1.6.0 - opossum@9.0.0: - resolution: {integrity: sha512-K76U0QkxOfUZamneQuzz+AP0fyfTJcCplZ2oZL93nxeupuJbN4s6uFNbmVCt4eWqqGqRnnowdFuBicJ1fLMVxw==} - engines: {node: ^24 || ^22 || ^20} - optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -20528,8 +20509,6 @@ snapshots: '@graphql-hive/signal@1.0.0': {} - '@graphql-hive/signal@2.0.0': {} - '@graphql-inspector/audit-command@4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2)': dependencies: '@graphql-inspector/commands': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) @@ -20603,6 +20582,13 @@ snapshots: object-inspect: 1.12.3 tslib: 2.6.2 + '@graphql-inspector/core@6.4.1(graphql@16.11.0)': + dependencies: + dependency-graph: 1.0.0 + graphql: 16.11.0 + object-inspect: 1.13.2 + tslib: 2.6.2 + '@graphql-inspector/core@6.4.1(graphql@16.9.0)': dependencies: dependency-graph: 1.0.0 @@ -27195,10 +27181,6 @@ snapshots: '@types/object-hash@3.0.6': {} - '@types/opossum@8.1.9': - dependencies: - '@types/node': 22.10.5 - '@types/oracledb@6.5.2': dependencies: '@types/node': 22.10.5 @@ -33856,8 +33838,6 @@ snapshots: transitivePeerDependencies: - supports-color - opossum@9.0.0: {} - optionator@0.9.3: dependencies: '@aashutoshrathi/word-wrap': 1.2.6