-
Notifications
You must be signed in to change notification settings - Fork 123
feat(js-sdk): circuit breaker for usage reporting #7259
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
41655b6
feat: circuit breaker for usage reporting
n1ru4l 6700d59
config
n1ru4l 313884c
fix
n1ru4l cfc8ab7
g
n1ru4l 47b16b3
h
n1ru4l 0cc54ea
curcuit break on this level
n1ru4l 9de6fec
add playground
n1ru4l 0eaa641
changeset
n1ru4l 4a89f67
lint gods amogus
n1ru4l 0bba384
Apply suggestion from @n1ru4l
n1ru4l 4919684
update fixtures
n1ru4l ed3e4cb
cloudflare lol (#7261)
n1ru4l 6a5ad5d
optional config
n1ru4l cb25697
timestamp and action header
n1ru4l 2ace0c6
remove curcuit breaker timeout option (we already have fetch level re…
n1ru4l 7769de2
keep it out the logs
n1ru4l f93601b
tweak default configuration
n1ru4l b6ef3c0
organize
n1ru4l e7f5300
remove client timestamp
n1ru4l 262e3b9
cleanup
n1ru4l File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| --- | ||
| '@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, | ||
| }, | ||
| } | ||
| }) | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
52 changes: 52 additions & 0 deletions
52
packages/libraries/core/playground/agent-circuit-breaker.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| /** | ||
| * | ||
| * 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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,35 @@ | ||
| 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'; | ||
|
|
||
| type ReadOnlyResponse = Pick<Response, 'status' | 'text' | 'json' | 'statusText'>; | ||
|
|
||
| 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; | ||
|
|
@@ -48,7 +74,14 @@ export interface AgentOptions { | |
| * WHATWG Compatible fetch implementation | ||
| * used by the agent to send reports | ||
| */ | ||
| fetch?: typeof fetch; | ||
| fetch?: typeof defaultFetch; | ||
| /** | ||
| * Circuit Breaker Configuration. | ||
| * true -> Use default configuration | ||
| * false -> Disable | ||
| * object -> use custom configuration see {AgentCircuitBreakerConfiguration} | ||
| */ | ||
| circuitBreaker?: boolean | AgentCircuitBreakerConfiguration; | ||
| } | ||
|
|
||
| export function createAgent<TEvent>( | ||
|
|
@@ -67,23 +100,31 @@ export function createAgent<TEvent>( | |
| headers?(): Record<string, string>; | ||
| }, | ||
| ) { | ||
| const options: Required<Omit<AgentOptions, 'fetch'>> = { | ||
| const options: Required<Omit<AgentOptions, 'fetch' | 'circuitBreaker'>> & { | ||
| circuitBreaker: null | AgentCircuitBreakerConfiguration; | ||
| } = { | ||
| timeout: 30_000, | ||
| debug: false, | ||
| enabled: true, | ||
| minTimeout: 200, | ||
| maxRetries: 3, | ||
| sendInterval: 10_000, | ||
| maxSize: 25, | ||
| logger: console, | ||
| name: 'hive-client', | ||
| version, | ||
| ...pluginOptions, | ||
| circuitBreaker: | ||
| pluginOptions.circuitBreaker == null || pluginOptions.circuitBreaker === true | ||
| ? defaultCircuitBreakerConfiguration | ||
| : pluginOptions.circuitBreaker === false | ||
| ? null | ||
| : pluginOptions.circuitBreaker, | ||
| logger: createHiveLogger(pluginOptions.logger ?? console, '[agent]'), | ||
| }; | ||
|
|
||
| const enabled = options.enabled !== false; | ||
|
|
||
| let timeoutID: any = null; | ||
| let timeoutID: ReturnType<typeof setTimeout> | null = null; | ||
|
|
||
| function schedule() { | ||
| if (timeoutID) { | ||
|
|
@@ -143,6 +184,27 @@ export function createAgent<TEvent>( | |
| return send({ throwOnError: true, skipSchedule: true }); | ||
| } | ||
|
|
||
| async function sendHTTPCall(buffer: string | Buffer<ArrayBufferLike>): Promise<Response> { | ||
| 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: options.logger, | ||
| fetchImplementation: pluginOptions.fetch, | ||
| signal, | ||
| }); | ||
| } | ||
|
|
||
| async function send(sendOptions?: { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We are basically doing the following: |
||
| throwOnError?: boolean; | ||
| skipSchedule: boolean; | ||
|
|
@@ -160,23 +222,7 @@ export function createAgent<TEvent>( | |
| data.clear(); | ||
|
|
||
| debugLog(`Sending report (queue ${dataToSend})`); | ||
| 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: options.logger, | ||
| fetchImplementation: pluginOptions.fetch, | ||
| }) | ||
| const response = sendFromBreaker(buffer) | ||
| .then(res => { | ||
| debugLog(`Report sent!`); | ||
| return res; | ||
|
|
@@ -215,6 +261,74 @@ export function createAgent<TEvent>( | |
| }); | ||
| } | ||
|
|
||
| let breaker: CircuitBreakerInterface< | ||
| Parameters<typeof sendHTTPCall>, | ||
| ReturnType<typeof sendHTTPCall> | ||
| >; | ||
| let loadCircuitBreakerPromise: Promise<void> | null = null; | ||
| const breakerLogger = createHiveLogger(options.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<typeof breaker.fire>) { | ||
| 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, | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is a bit hard to unit test this functionality, so I added this file that I can play around with the values for the circuit breaker.
I encourage reviewers to do the same to get an understanding on how this works.