|
| 1 | +import CircuitBreaker from 'opossum'; |
| 2 | +import { fetch as defaultFetch } from '@whatwg-node/fetch'; |
1 | 3 | import { version } from '../version.js'; |
2 | 4 | import { http } from './http-client.js'; |
3 | 5 | import type { Logger } from './types.js'; |
4 | 6 |
|
5 | 7 | type ReadOnlyResponse = Pick<Response, 'status' | 'text' | 'json' | 'statusText'>; |
6 | 8 |
|
| 9 | +export type AgentCircuitBreakerConfiguration = { |
| 10 | + /** after which time a request should be treated as a timeout in milleseconds */ |
| 11 | + timeout: number; |
| 12 | + /** percentage after what the circuit breaker should kick in. */ |
| 13 | + errorThresholdPercentage: number; |
| 14 | + /** count of requests before starting evaluating. */ |
| 15 | + volumeThreshold: number; |
| 16 | + /** after what time the circuit breaker is resetted in milliseconds */ |
| 17 | + resetTimeout: number; |
| 18 | +}; |
| 19 | + |
| 20 | +const defaultCircuitBreakerConfiguration: AgentCircuitBreakerConfiguration = { |
| 21 | + // if call takes > 5s, count as a failure |
| 22 | + timeout: 5000, |
| 23 | + // trip if 50% of calls fail |
| 24 | + errorThresholdPercentage: 50, |
| 25 | + // need at least 5 calls before evaluating |
| 26 | + volumeThreshold: 5, |
| 27 | + // after 30s, try half-open state |
| 28 | + resetTimeout: 30000, |
| 29 | +}; |
| 30 | + |
7 | 31 | export interface AgentOptions { |
8 | 32 | enabled?: boolean; |
9 | 33 | name?: string; |
@@ -47,7 +71,11 @@ export interface AgentOptions { |
47 | 71 | * WHATWG Compatible fetch implementation |
48 | 72 | * used by the agent to send reports |
49 | 73 | */ |
50 | | - fetch?: typeof fetch; |
| 74 | + fetch?: typeof defaultFetch; |
| 75 | + /** |
| 76 | + * Circuit Breaker Configuration |
| 77 | + */ |
| 78 | + circuitBreaker?: AgentCircuitBreakerConfiguration; |
51 | 79 | } |
52 | 80 |
|
53 | 81 | export function createAgent<TEvent>( |
@@ -76,12 +104,13 @@ export function createAgent<TEvent>( |
76 | 104 | maxSize: 25, |
77 | 105 | logger: console, |
78 | 106 | name: 'hive-client', |
| 107 | + circuitBreaker: defaultCircuitBreakerConfiguration, |
79 | 108 | ...pluginOptions, |
80 | 109 | }; |
81 | 110 |
|
82 | 111 | const enabled = options.enabled !== false; |
83 | 112 |
|
84 | | - let timeoutID: any = null; |
| 113 | + let timeoutID: ReturnType<typeof setTimeout> | null = null; |
85 | 114 |
|
86 | 115 | function schedule() { |
87 | 116 | if (timeoutID) { |
@@ -131,20 +160,22 @@ export function createAgent<TEvent>( |
131 | 160 |
|
132 | 161 | if (data.size() >= options.maxSize) { |
133 | 162 | debugLog('Sending immediately'); |
134 | | - setImmediate(() => send({ throwOnError: false, skipSchedule: true })); |
| 163 | + setImmediate(() => breaker.fire({ throwOnError: false, skipSchedule: true })); |
135 | 164 | } |
136 | 165 | } |
137 | 166 |
|
138 | 167 | function sendImmediately(event: TEvent): Promise<ReadOnlyResponse | null> { |
139 | 168 | data.set(event); |
140 | 169 | debugLog('Sending immediately'); |
141 | | - return send({ throwOnError: true, skipSchedule: true }); |
| 170 | + return breaker.fire({ throwOnError: true, skipSchedule: true }); |
142 | 171 | } |
143 | 172 |
|
144 | 173 | async function send(sendOptions?: { |
145 | 174 | throwOnError?: boolean; |
146 | 175 | skipSchedule: boolean; |
147 | 176 | }): Promise<ReadOnlyResponse | null> { |
| 177 | + const signal: AbortSignal = breaker.getSignal(); |
| 178 | + |
148 | 179 | if (!data.size() || !enabled) { |
149 | 180 | if (!sendOptions?.skipSchedule) { |
150 | 181 | schedule(); |
@@ -174,6 +205,7 @@ export function createAgent<TEvent>( |
174 | 205 | }, |
175 | 206 | logger: options.logger, |
176 | 207 | fetchImplementation: pluginOptions.fetch, |
| 208 | + signal, |
177 | 209 | }) |
178 | 210 | .then(res => { |
179 | 211 | debugLog(`Report sent!`); |
@@ -207,12 +239,21 @@ export function createAgent<TEvent>( |
207 | 239 | await Promise.all(inProgressCaptures); |
208 | 240 | } |
209 | 241 |
|
210 | | - await send({ |
| 242 | + await breaker.fire({ |
211 | 243 | skipSchedule: true, |
212 | 244 | throwOnError: false, |
213 | 245 | }); |
214 | 246 | } |
215 | 247 |
|
| 248 | + const breaker = new CircuitBreaker(send, { |
| 249 | + ...options.circuitBreaker, |
| 250 | + autoRenewAbortController: true, |
| 251 | + }); |
| 252 | + |
| 253 | + breaker.on('open', () => errorLog('circuit opened - backend unreachable')); |
| 254 | + breaker.on('halfOpen', () => debugLog('testing backend connectivity')); |
| 255 | + breaker.on('close', () => debugLog('backend recovered - circuit closed')); |
| 256 | + |
216 | 257 | return { |
217 | 258 | capture, |
218 | 259 | sendImmediately, |
|
0 commit comments