Feat/replace circuit breaker with bounded retry (#393)
Feat/replace circuit breaker with bounded retry (#393)
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
import { jest } from '@jest/globals'
|
||||
import {
|
||||
withBoundedRetry,
|
||||
BoundedRetryGiveUpError,
|
||||
BoundedRetryAbortedError,
|
||||
WHATSAPP_BACKOFF_DELAYS,
|
||||
DEFAULT_TTL_MS
|
||||
} from '../../Utils/bounded-retry'
|
||||
|
||||
// All tests use very short real delays (1-50ms) so they run fast.
|
||||
// This sidesteps the fragility of jest fake timers + async chains.
|
||||
|
||||
describe('bounded-retry — WhatsApp-aligned per-operation retry', () => {
|
||||
test('default delay sequence matches WhatsApp Android empirical capture', () => {
|
||||
// Frida trace: 3000 -> 10000 -> 60000 -> ~64000 -> 120000 ms
|
||||
expect(WHATSAPP_BACKOFF_DELAYS).toEqual([3000, 10000, 60000, 60000, 120000])
|
||||
})
|
||||
|
||||
test('default TTL is 10 minutes (matches empirical stabilisation + safety buffer)', () => {
|
||||
expect(DEFAULT_TTL_MS).toBe(600_000)
|
||||
})
|
||||
|
||||
test('first-attempt success returns immediately, no retries', async () => {
|
||||
const op = jest.fn<() => Promise<string>>().mockResolvedValue('ok')
|
||||
const result = await withBoundedRetry(op, { name: 'fast-success' })
|
||||
expect(result).toBe('ok')
|
||||
expect(op).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('retries with the configured delay sequence after failures', async () => {
|
||||
const op = jest
|
||||
.fn<() => Promise<number>>()
|
||||
.mockRejectedValueOnce(new Error('try1'))
|
||||
.mockRejectedValueOnce(new Error('try2'))
|
||||
.mockResolvedValueOnce(42)
|
||||
|
||||
const onRetry = jest.fn<(err: Error, attempt: number, delayMs: number) => void>()
|
||||
const result = await withBoundedRetry(op, {
|
||||
name: 'three-tries',
|
||||
delays: [10, 20, 40],
|
||||
jitter: 0,
|
||||
onRetry
|
||||
})
|
||||
|
||||
expect(result).toBe(42)
|
||||
expect(op).toHaveBeenCalledTimes(3)
|
||||
expect(onRetry).toHaveBeenCalledTimes(2)
|
||||
expect(onRetry.mock.calls[0]![2]).toBe(10)
|
||||
expect(onRetry.mock.calls[1]![2]).toBe(20)
|
||||
})
|
||||
|
||||
test('TTL exhaustion throws BoundedRetryGiveUpError (no infinite retries)', async () => {
|
||||
const op = jest.fn<() => Promise<string>>().mockRejectedValue(new Error('always-fails'))
|
||||
|
||||
await expect(
|
||||
withBoundedRetry(op, {
|
||||
name: 'ttl-test',
|
||||
delays: [10],
|
||||
jitter: 0,
|
||||
ttlMs: 50
|
||||
})
|
||||
).rejects.toBeInstanceOf(BoundedRetryGiveUpError)
|
||||
|
||||
// Should have tried multiple times before giving up
|
||||
expect(op.mock.calls.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('per-attempt timeout fires for slow operations', async () => {
|
||||
const slowOp = jest.fn<() => Promise<string>>(() => new Promise(() => {})) // hangs
|
||||
|
||||
await expect(
|
||||
withBoundedRetry(slowOp, {
|
||||
name: 'slow',
|
||||
delays: [5],
|
||||
jitter: 0,
|
||||
ttlMs: 200,
|
||||
perAttemptTimeoutMs: 30
|
||||
})
|
||||
).rejects.toBeInstanceOf(BoundedRetryGiveUpError)
|
||||
|
||||
// Multiple attempts should have been tried (each timed out at 30ms)
|
||||
expect(slowOp.mock.calls.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test('shouldRetry predicate can short-circuit retries', async () => {
|
||||
const op = jest
|
||||
.fn<() => Promise<string>>()
|
||||
.mockRejectedValueOnce(new Error('transient'))
|
||||
.mockRejectedValueOnce(new Error('FATAL: do not retry'))
|
||||
|
||||
await expect(
|
||||
withBoundedRetry(op, {
|
||||
name: 'predicate-test',
|
||||
delays: [1],
|
||||
jitter: 0,
|
||||
shouldRetry: err => !err.message.startsWith('FATAL')
|
||||
})
|
||||
).rejects.toThrow('FATAL: do not retry')
|
||||
|
||||
expect(op).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test('AbortSignal cancels in-flight retry sleep', async () => {
|
||||
const op = jest.fn<() => Promise<string>>().mockRejectedValue(new Error('fail'))
|
||||
const ctrl = new AbortController()
|
||||
|
||||
const promise = withBoundedRetry(op, {
|
||||
name: 'abort-test',
|
||||
delays: [10_000], // long sleep so abort can happen during it
|
||||
jitter: 0,
|
||||
signal: ctrl.signal
|
||||
})
|
||||
|
||||
// First attempt fires immediately; sleep starts
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
ctrl.abort()
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(BoundedRetryAbortedError)
|
||||
})
|
||||
|
||||
test('two concurrent retry chains do NOT interfere with each other (per-op isolation)', async () => {
|
||||
// Key advantage over circuit breaker: failures in one chain do not
|
||||
// block another chain. With circuit breaker, 5 failures from chain A
|
||||
// would block chain B too. With bounded-retry, B is independent.
|
||||
|
||||
const flakyOp = jest
|
||||
.fn<() => Promise<string>>()
|
||||
.mockRejectedValueOnce(new Error('a1'))
|
||||
.mockResolvedValueOnce('a-ok')
|
||||
|
||||
const fastOp = jest.fn<() => Promise<string>>().mockResolvedValue('b-ok')
|
||||
|
||||
// Start a (will retry once after 30ms) and b (immediate)
|
||||
const aPromise = withBoundedRetry(flakyOp, { name: 'a', delays: [30], jitter: 0 })
|
||||
const bPromise = withBoundedRetry(fastOp, { name: 'b', delays: [30], jitter: 0 })
|
||||
|
||||
// b should resolve right away
|
||||
const bResult = await bPromise
|
||||
expect(bResult).toBe('b-ok')
|
||||
expect(fastOp).toHaveBeenCalledTimes(1)
|
||||
|
||||
// a still running — wait for it
|
||||
const aResult = await aPromise
|
||||
expect(aResult).toBe('a-ok')
|
||||
expect(flakyOp).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test('delay sequence caps at last value (no unbounded growth)', async () => {
|
||||
const op = jest.fn<() => Promise<string>>().mockRejectedValue(new Error('fail'))
|
||||
const onRetry = jest.fn<(err: Error, attempt: number, delayMs: number) => void>()
|
||||
|
||||
await expect(
|
||||
withBoundedRetry(op, {
|
||||
name: 'cap-test',
|
||||
delays: [5, 10, 15], // 3 distinct steps; 4th+ retry should reuse 15
|
||||
jitter: 0,
|
||||
ttlMs: 300,
|
||||
onRetry
|
||||
})
|
||||
).rejects.toBeInstanceOf(BoundedRetryGiveUpError)
|
||||
|
||||
// Find delays >= 4th retry — all should be capped at 15
|
||||
const lateRetries = onRetry.mock.calls.filter(c => c[1] >= 4)
|
||||
expect(lateRetries.length).toBeGreaterThan(0)
|
||||
for (const call of lateRetries) {
|
||||
expect(call[2]).toBeLessThanOrEqual(15)
|
||||
}
|
||||
})
|
||||
|
||||
test('jitter randomises delay within +/- factor', async () => {
|
||||
const op = jest.fn<() => Promise<string>>().mockRejectedValue(new Error('fail'))
|
||||
const observed: number[] = []
|
||||
|
||||
// Single run, capture multiple onRetry calls (each with different jitter)
|
||||
await withBoundedRetry(op, {
|
||||
name: 'jitter-test',
|
||||
delays: [50], // base delay
|
||||
jitter: 0.3, // +/- 30% -> [35, 65]
|
||||
ttlMs: 1000, // ttl high enough for ~10 retries
|
||||
perAttemptTimeoutMs: 5,
|
||||
onRetry: (_, __, d) => observed.push(d)
|
||||
}).catch(() => {})
|
||||
|
||||
expect(observed.length).toBeGreaterThanOrEqual(3)
|
||||
const unique = new Set(observed)
|
||||
expect(unique.size).toBeGreaterThan(1)
|
||||
// Every observed delay must be within jitter window — but the LAST one
|
||||
// may be capped by remaining ttl budget, so check all but the last
|
||||
const inWindow = observed.slice(0, -1)
|
||||
for (const d of inWindow) {
|
||||
expect(d).toBeGreaterThanOrEqual(35)
|
||||
expect(d).toBeLessThanOrEqual(65)
|
||||
}
|
||||
})
|
||||
|
||||
test('delay does not exceed remaining TTL budget', async () => {
|
||||
const op = jest.fn<() => Promise<string>>().mockRejectedValue(new Error('fail'))
|
||||
const onRetry = jest.fn<(err: Error, attempt: number, delayMs: number) => void>()
|
||||
|
||||
await withBoundedRetry(op, {
|
||||
name: 'budget-cap',
|
||||
delays: [200], // would exceed ttl
|
||||
jitter: 0,
|
||||
ttlMs: 50, // very short
|
||||
onRetry
|
||||
}).catch(() => {})
|
||||
|
||||
// All scheduled delays should be capped to remaining budget
|
||||
for (const call of onRetry.mock.calls) {
|
||||
expect(call[2]).toBeLessThanOrEqual(50)
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Tests for fixes from PR #393 review ──────────────────────────────
|
||||
|
||||
test('TTL is enforced BEFORE the next attempt (not just after)', async () => {
|
||||
// Regression: previously TTL was only checked after a failure, so a
|
||||
// new attempt could start with 0ms remaining and run for the full
|
||||
// per-attempt timeout, overshooting wall-clock budget by that much.
|
||||
const slowOp = jest.fn<() => Promise<string>>(() => new Promise(resolve => setTimeout(() => resolve('late'), 200)))
|
||||
|
||||
const start = Date.now()
|
||||
await expect(
|
||||
withBoundedRetry(slowOp, {
|
||||
name: 'ttl-strict',
|
||||
delays: [10],
|
||||
jitter: 0,
|
||||
ttlMs: 50,
|
||||
perAttemptTimeoutMs: 1000 // huge, must be capped by TTL
|
||||
})
|
||||
).rejects.toBeInstanceOf(BoundedRetryGiveUpError)
|
||||
|
||||
const elapsed = Date.now() - start
|
||||
// With strict TTL enforcement, total runtime should be very close to ttlMs.
|
||||
// Allow up to 100ms slack for Node timer + microtask scheduling.
|
||||
expect(elapsed).toBeLessThan(150)
|
||||
})
|
||||
|
||||
test('per-attempt timeout is capped by remaining TTL budget', async () => {
|
||||
// If user passes perAttemptTimeoutMs > ttlMs, the cap should apply.
|
||||
const slowOp = jest.fn<() => Promise<string>>(() => new Promise(() => {})) // hangs forever
|
||||
|
||||
const start = Date.now()
|
||||
await expect(
|
||||
withBoundedRetry(slowOp, {
|
||||
name: 'attempt-cap',
|
||||
delays: [1],
|
||||
jitter: 0,
|
||||
ttlMs: 30,
|
||||
perAttemptTimeoutMs: 5000 // way bigger than ttl
|
||||
})
|
||||
).rejects.toBeInstanceOf(BoundedRetryGiveUpError)
|
||||
|
||||
const elapsed = Date.now() - start
|
||||
expect(elapsed).toBeLessThan(150) // not 5000ms!
|
||||
})
|
||||
|
||||
test('operation receives an AbortSignal that is aborted on per-attempt timeout', async () => {
|
||||
// New API: operation can opt into cancellation via the signal arg.
|
||||
const aborts: boolean[] = []
|
||||
const opThatRespectsSignal = jest.fn(
|
||||
(signal?: AbortSignal) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const timer = setTimeout(() => resolve('late'), 100)
|
||||
signal?.addEventListener('abort', () => {
|
||||
clearTimeout(timer)
|
||||
aborts.push(true)
|
||||
reject(new Error('aborted by signal'))
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
await withBoundedRetry(opThatRespectsSignal, {
|
||||
name: 'signal-aware',
|
||||
delays: [1],
|
||||
jitter: 0,
|
||||
ttlMs: 80,
|
||||
perAttemptTimeoutMs: 20 // smaller than 100ms op duration
|
||||
}).catch(() => {})
|
||||
|
||||
expect(aborts.length).toBeGreaterThan(0) // at least one attempt was aborted
|
||||
})
|
||||
|
||||
test('sleep listener is removed when timeout completes normally (no leak)', async () => {
|
||||
// Regression: sleep used signal.addEventListener with { once: true } but
|
||||
// never removed the listener on timer-resolve. With many retries on a
|
||||
// shared signal, listeners would accumulate.
|
||||
//
|
||||
// Make the failure mode observable by spying on add/removeEventListener
|
||||
// directly so the assertion fails if cleanup is removed.
|
||||
const ctrl = new AbortController()
|
||||
const realAdd = ctrl.signal.addEventListener.bind(ctrl.signal)
|
||||
const realRemove = ctrl.signal.removeEventListener.bind(ctrl.signal)
|
||||
let addCount = 0
|
||||
let removeCount = 0
|
||||
ctrl.signal.addEventListener = ((type: string, listener: never, opts?: never) => {
|
||||
if (type === 'abort') addCount++
|
||||
return realAdd(type, listener, opts)
|
||||
}) as typeof ctrl.signal.addEventListener
|
||||
ctrl.signal.removeEventListener = ((type: string, listener: never, opts?: never) => {
|
||||
if (type === 'abort') removeCount++
|
||||
return realRemove(type, listener, opts)
|
||||
}) as typeof ctrl.signal.removeEventListener
|
||||
|
||||
const op = jest
|
||||
.fn<() => Promise<string>>()
|
||||
.mockRejectedValueOnce(new Error('1'))
|
||||
.mockRejectedValueOnce(new Error('2'))
|
||||
.mockRejectedValueOnce(new Error('3'))
|
||||
.mockResolvedValueOnce('ok')
|
||||
|
||||
const result = await withBoundedRetry(op, {
|
||||
name: 'no-leak',
|
||||
delays: [5],
|
||||
jitter: 0,
|
||||
ttlMs: 500,
|
||||
signal: ctrl.signal
|
||||
})
|
||||
|
||||
expect(result).toBe('ok')
|
||||
expect(op).toHaveBeenCalledTimes(4)
|
||||
// If sleep() leaks listeners, addCount > removeCount. Each retry adds
|
||||
// listeners (1 sleep + 1 outer-abort forwarding) and must remove the
|
||||
// same number on settlement.
|
||||
expect(addCount).toBeGreaterThan(0)
|
||||
expect(addCount).toBe(removeCount)
|
||||
})
|
||||
|
||||
test('logger receives structured logs for retry, give-up, and recovery', async () => {
|
||||
const debugCalls: unknown[][] = []
|
||||
const infoCalls: unknown[][] = []
|
||||
const warnCalls: unknown[][] = []
|
||||
const fakeLogger = {
|
||||
debug: (...args: unknown[]) => debugCalls.push(args),
|
||||
info: (...args: unknown[]) => infoCalls.push(args),
|
||||
warn: (...args: unknown[]) => warnCalls.push(args),
|
||||
error: () => {},
|
||||
fatal: () => {},
|
||||
trace: () => {},
|
||||
child: () => fakeLogger,
|
||||
level: 'debug'
|
||||
}
|
||||
|
||||
const op = jest
|
||||
.fn<() => Promise<string>>()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce('ok')
|
||||
|
||||
const result = await withBoundedRetry(op, {
|
||||
name: 'logged-op',
|
||||
delays: [5],
|
||||
jitter: 0,
|
||||
ttlMs: 200,
|
||||
logger: fakeLogger as never
|
||||
})
|
||||
|
||||
expect(result).toBe('ok')
|
||||
// Should have logged the scheduling at debug level
|
||||
expect(debugCalls.length).toBeGreaterThanOrEqual(1)
|
||||
// Should have logged the recovery at info level
|
||||
expect(infoCalls.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('logger reports give-up via warn when TTL exceeded', async () => {
|
||||
const warnCalls: unknown[][] = []
|
||||
const fakeLogger = {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: (...args: unknown[]) => warnCalls.push(args),
|
||||
error: () => {},
|
||||
fatal: () => {},
|
||||
trace: () => {},
|
||||
child: () => fakeLogger,
|
||||
level: 'debug'
|
||||
}
|
||||
|
||||
const op = jest.fn<() => Promise<string>>().mockRejectedValue(new Error('always-fails'))
|
||||
|
||||
await expect(
|
||||
withBoundedRetry(op, {
|
||||
name: 'logged-fail',
|
||||
delays: [5],
|
||||
jitter: 0,
|
||||
ttlMs: 30,
|
||||
logger: fakeLogger as never
|
||||
})
|
||||
).rejects.toBeInstanceOf(BoundedRetryGiveUpError)
|
||||
|
||||
expect(warnCalls.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
@@ -1,371 +0,0 @@
|
||||
/**
|
||||
* Testes unitários para circuit-breaker.ts
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'
|
||||
import {
|
||||
CircuitBreaker,
|
||||
CircuitBreakerRegistry,
|
||||
CircuitOpenError,
|
||||
type CircuitState,
|
||||
CircuitTimeoutError,
|
||||
createCircuitBreaker,
|
||||
getCircuitHealth,
|
||||
globalCircuitRegistry,
|
||||
withCircuitBreaker
|
||||
} from '../../Utils/circuit-breaker.js'
|
||||
|
||||
describe('CircuitBreaker', () => {
|
||||
let breaker: CircuitBreaker
|
||||
|
||||
beforeEach(() => {
|
||||
breaker = createCircuitBreaker({
|
||||
name: 'test',
|
||||
failureThreshold: 3,
|
||||
successThreshold: 2,
|
||||
resetTimeout: 100,
|
||||
timeout: 1000,
|
||||
volumeThreshold: 3,
|
||||
collectMetrics: false
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
breaker.destroy()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should start in closed state', () => {
|
||||
expect(breaker.getState()).toBe('closed')
|
||||
expect(breaker.isClosed()).toBe(true)
|
||||
expect(breaker.isOpen()).toBe(false)
|
||||
expect(breaker.isHalfOpen()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('successful operations', () => {
|
||||
it('should execute successful operations', async () => {
|
||||
const result = await breaker.execute(() => 'success')
|
||||
expect(result).toBe('success')
|
||||
})
|
||||
|
||||
it('should execute async operations', async () => {
|
||||
const result = await breaker.execute(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
return 'async success'
|
||||
})
|
||||
expect(result).toBe('async success')
|
||||
})
|
||||
|
||||
it('should track successful calls in stats', async () => {
|
||||
await breaker.execute(() => 'success')
|
||||
await breaker.execute(() => 'success')
|
||||
|
||||
const stats = breaker.getStats()
|
||||
expect(stats.totalCalls).toBe(2)
|
||||
expect(stats.totalSuccesses).toBe(2)
|
||||
expect(stats.totalFailures).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure handling', () => {
|
||||
it('should open after reaching failure threshold', async () => {
|
||||
const failingOp = () => {
|
||||
throw new Error('Failure')
|
||||
}
|
||||
|
||||
// Reach failure threshold
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await expect(breaker.execute(failingOp)).rejects.toThrow('Failure')
|
||||
}
|
||||
|
||||
expect(breaker.isOpen()).toBe(true)
|
||||
})
|
||||
|
||||
it('should not open before reaching threshold', async () => {
|
||||
const failingOp = () => {
|
||||
throw new Error('Failure')
|
||||
}
|
||||
|
||||
// Below threshold
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await expect(breaker.execute(failingOp)).rejects.toThrow('Failure')
|
||||
}
|
||||
|
||||
expect(breaker.isClosed()).toBe(true)
|
||||
})
|
||||
|
||||
it('should reset failure count on success', async () => {
|
||||
const failingOp = () => {
|
||||
throw new Error('Failure')
|
||||
}
|
||||
|
||||
await expect(breaker.execute(failingOp)).rejects.toThrow()
|
||||
await expect(breaker.execute(failingOp)).rejects.toThrow()
|
||||
|
||||
// Success resets failures
|
||||
await breaker.execute(() => 'success')
|
||||
|
||||
// Only 1 more failure needed (already have 2 in window, need 3 total)
|
||||
await expect(breaker.execute(failingOp)).rejects.toThrow()
|
||||
|
||||
// Should open now (3 failures in window)
|
||||
expect(breaker.isOpen()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('open state', () => {
|
||||
beforeEach(async () => {
|
||||
// Force open
|
||||
breaker.trip()
|
||||
})
|
||||
|
||||
it('should reject operations when open', async () => {
|
||||
await expect(breaker.execute(() => 'success')).rejects.toThrow(CircuitOpenError)
|
||||
})
|
||||
|
||||
it('should transition to half-open after reset timeout', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
expect(breaker.isHalfOpen()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('half-open state', () => {
|
||||
beforeEach(async () => {
|
||||
breaker.trip()
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
})
|
||||
|
||||
it('should close after success threshold', async () => {
|
||||
expect(breaker.isHalfOpen()).toBe(true)
|
||||
|
||||
// Success threshold is 2
|
||||
await breaker.execute(() => 'success')
|
||||
await breaker.execute(() => 'success')
|
||||
|
||||
expect(breaker.isClosed()).toBe(true)
|
||||
})
|
||||
|
||||
it('should reopen on failure', async () => {
|
||||
expect(breaker.isHalfOpen()).toBe(true)
|
||||
|
||||
await expect(
|
||||
breaker.execute(() => {
|
||||
throw new Error('Failure')
|
||||
})
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(breaker.isOpen()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('manual controls', () => {
|
||||
it('should force open with trip()', () => {
|
||||
breaker.trip()
|
||||
expect(breaker.isOpen()).toBe(true)
|
||||
})
|
||||
|
||||
it('should force close with reset()', async () => {
|
||||
breaker.trip()
|
||||
breaker.reset()
|
||||
expect(breaker.isClosed()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('timeout', () => {
|
||||
it('should timeout slow operations', async () => {
|
||||
const slowBreaker = createCircuitBreaker({
|
||||
name: 'slow',
|
||||
timeout: 50,
|
||||
collectMetrics: false
|
||||
})
|
||||
|
||||
await expect(slowBreaker.execute(() => new Promise(resolve => setTimeout(resolve, 200)))).rejects.toThrow(
|
||||
CircuitTimeoutError
|
||||
)
|
||||
|
||||
slowBreaker.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('events', () => {
|
||||
it('should emit state-change event', async () => {
|
||||
const stateChanges: Array<{ from: CircuitState; to: CircuitState }> = []
|
||||
|
||||
breaker.on('state-change', change => {
|
||||
stateChanges.push(change)
|
||||
})
|
||||
|
||||
breaker.trip()
|
||||
breaker.reset()
|
||||
|
||||
expect(stateChanges).toHaveLength(2)
|
||||
expect(stateChanges[0]).toEqual({ from: 'closed', to: 'open' })
|
||||
expect(stateChanges[1]).toEqual({ from: 'open', to: 'closed' })
|
||||
})
|
||||
|
||||
it('should emit success and failure events', async () => {
|
||||
let successCount = 0
|
||||
let failureCount = 0
|
||||
|
||||
breaker.on('success', () => successCount++)
|
||||
breaker.on('failure', () => failureCount++)
|
||||
|
||||
await breaker.execute(() => 'success')
|
||||
await expect(
|
||||
breaker.execute(() => {
|
||||
throw new Error()
|
||||
})
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(successCount).toBe(1)
|
||||
expect(failureCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isFailure predicate', () => {
|
||||
it('should use custom isFailure predicate', async () => {
|
||||
const customBreaker = createCircuitBreaker({
|
||||
name: 'custom',
|
||||
failureThreshold: 1,
|
||||
volumeThreshold: 1,
|
||||
shouldCountError: (error: any) => error.message !== 'Ignored',
|
||||
collectMetrics: false
|
||||
})
|
||||
|
||||
// This error should be ignored
|
||||
await expect(
|
||||
customBreaker.execute(() => {
|
||||
throw new Error('Ignored')
|
||||
})
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(customBreaker.isClosed()).toBe(true)
|
||||
|
||||
// This should trip the breaker
|
||||
await expect(
|
||||
customBreaker.execute(() => {
|
||||
throw new Error('Real failure')
|
||||
})
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(customBreaker.isOpen()).toBe(true)
|
||||
|
||||
customBreaker.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sync operations', () => {
|
||||
it('should execute sync operations', () => {
|
||||
const result = breaker.executeSync(() => 'sync result')
|
||||
expect(result).toBe('sync result')
|
||||
})
|
||||
|
||||
it('should handle sync failures', () => {
|
||||
expect(() =>
|
||||
breaker.executeSync(() => {
|
||||
throw new Error('Sync failure')
|
||||
})
|
||||
).toThrow('Sync failure')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('CircuitBreakerRegistry', () => {
|
||||
let registry: CircuitBreakerRegistry
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new CircuitBreakerRegistry()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
registry.destroyAll()
|
||||
})
|
||||
|
||||
it('should create and retrieve circuit breakers', () => {
|
||||
const breaker1 = registry.get('test1')
|
||||
const breaker2 = registry.get('test1')
|
||||
|
||||
expect(breaker1).toBe(breaker2)
|
||||
})
|
||||
|
||||
it('should check if breaker exists', () => {
|
||||
registry.get('exists')
|
||||
|
||||
expect(registry.has('exists')).toBe(true)
|
||||
expect(registry.has('notexists')).toBe(false)
|
||||
})
|
||||
|
||||
it('should remove breaker', () => {
|
||||
registry.get('toRemove')
|
||||
expect(registry.remove('toRemove')).toBe(true)
|
||||
expect(registry.has('toRemove')).toBe(false)
|
||||
})
|
||||
|
||||
it('should get all stats', () => {
|
||||
registry.get('breaker1')
|
||||
registry.get('breaker2')
|
||||
|
||||
const stats = registry.getAllStats()
|
||||
|
||||
expect(stats).toHaveProperty('breaker1')
|
||||
expect(stats).toHaveProperty('breaker2')
|
||||
})
|
||||
|
||||
it('should reset all breakers', async () => {
|
||||
const breaker = registry.get('test')
|
||||
breaker.trip()
|
||||
|
||||
registry.resetAll()
|
||||
|
||||
expect(breaker.isClosed()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('withCircuitBreaker', () => {
|
||||
it('should wrap function with circuit breaker', async () => {
|
||||
let callCount = 0
|
||||
|
||||
const protectedFn = withCircuitBreaker(
|
||||
async () => {
|
||||
callCount++
|
||||
return 'result'
|
||||
},
|
||||
{
|
||||
name: 'wrapped-fn',
|
||||
collectMetrics: false
|
||||
}
|
||||
)
|
||||
|
||||
const result = await protectedFn()
|
||||
|
||||
expect(result).toBe('result')
|
||||
expect(callCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCircuitHealth', () => {
|
||||
beforeEach(() => {
|
||||
globalCircuitRegistry.destroyAll()
|
||||
})
|
||||
|
||||
it('should report healthy when all circuits closed', () => {
|
||||
globalCircuitRegistry.get('healthy1')
|
||||
globalCircuitRegistry.get('healthy2')
|
||||
|
||||
const health = getCircuitHealth()
|
||||
|
||||
expect(health.healthy).toBe(true)
|
||||
expect(health.openCircuits).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should report unhealthy when circuit is open', () => {
|
||||
const breaker = globalCircuitRegistry.get('unhealthy')
|
||||
breaker.trip()
|
||||
|
||||
const health = getCircuitHealth()
|
||||
|
||||
expect(health.healthy).toBe(false)
|
||||
expect(health.openCircuits).toContain('unhealthy')
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
* Testes unitários para retry-utils.ts
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
|
||||
import { describe, expect, it, jest } from '@jest/globals'
|
||||
import {
|
||||
calculateDelay,
|
||||
createRetrier,
|
||||
@@ -11,12 +11,10 @@ import {
|
||||
retry,
|
||||
RETRY_BACKOFF_DELAYS,
|
||||
RETRY_JITTER_FACTOR,
|
||||
retryable,
|
||||
RetryAbortedError,
|
||||
retryConfigs,
|
||||
type RetryContext,
|
||||
RetryExhaustedError,
|
||||
RetryManager,
|
||||
retryPredicates,
|
||||
retryWithResult
|
||||
} from '../../Utils/retry-utils.js'
|
||||
@@ -315,103 +313,6 @@ describe('createRetrier', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('retryable', () => {
|
||||
it('should wrap function with retry', async () => {
|
||||
let attempts = 0
|
||||
|
||||
const fn = retryable(
|
||||
() => {
|
||||
attempts++
|
||||
if (attempts < 2) throw new Error('Failing')
|
||||
return 'wrapped result'
|
||||
},
|
||||
{
|
||||
maxAttempts: 5,
|
||||
baseDelay: 10,
|
||||
collectMetrics: false
|
||||
}
|
||||
)
|
||||
|
||||
const result = await fn()
|
||||
|
||||
expect(result).toBe('wrapped result')
|
||||
expect(attempts).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RetryManager', () => {
|
||||
let manager: RetryManager
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new RetryManager({ baseDelay: 200, collectMetrics: false })
|
||||
})
|
||||
|
||||
it('should execute operation with id', async () => {
|
||||
const result = await manager.execute('op1', () => 'result')
|
||||
expect(result).toBe('result')
|
||||
})
|
||||
|
||||
it('should cancel active retry', async () => {
|
||||
let attempt = 0
|
||||
|
||||
const promise = manager.execute('cancelable', async () => {
|
||||
attempt++
|
||||
if (attempt === 1) {
|
||||
throw new Error('Temporary failure')
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
return 'result'
|
||||
})
|
||||
|
||||
// Cancel during retry delay
|
||||
setTimeout(() => manager.cancel('cancelable'), 100)
|
||||
|
||||
await expect(promise).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should check if operation is active', async () => {
|
||||
let resolve: () => void
|
||||
const promise = manager.execute(
|
||||
'active',
|
||||
() =>
|
||||
new Promise<string>(r => {
|
||||
resolve = () => r('done')
|
||||
})
|
||||
)
|
||||
|
||||
expect(manager.isActive('active')).toBe(true)
|
||||
|
||||
resolve!()
|
||||
await promise
|
||||
|
||||
expect(manager.isActive('active')).toBe(false)
|
||||
})
|
||||
|
||||
it('should cancel all operations', async () => {
|
||||
const promises = [
|
||||
manager.execute('op1', () => new Promise((_, reject) => setTimeout(() => reject(new Error()), 1000))),
|
||||
manager.execute('op2', () => new Promise((_, reject) => setTimeout(() => reject(new Error()), 1000)))
|
||||
]
|
||||
|
||||
setTimeout(() => manager.cancelAll(), 20)
|
||||
|
||||
await expect(Promise.all(promises)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should emit events', async () => {
|
||||
const events: string[] = []
|
||||
|
||||
manager.on('attempt', () => events.push('attempt'))
|
||||
manager.on('success', () => events.push('success'))
|
||||
|
||||
await manager.execute('test', () => 'result')
|
||||
|
||||
expect(events).toContain('attempt')
|
||||
expect(events).toContain('success')
|
||||
})
|
||||
})
|
||||
|
||||
describe('retryPredicates', () => {
|
||||
describe('always', () => {
|
||||
it('should always return true', () => {
|
||||
|
||||
@@ -29,8 +29,7 @@ const TimeMs = {
|
||||
jest.mock('../../Utils/prometheus-metrics.js', () => ({
|
||||
metrics: {
|
||||
socketEvents: { inc: jest.fn() },
|
||||
errors: { inc: jest.fn() },
|
||||
circuitBreakerTrips: { inc: jest.fn() }
|
||||
errors: { inc: jest.fn() }
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -59,7 +58,6 @@ describe('UnifiedSessionManager', () => {
|
||||
manager = createUnifiedSessionManager({
|
||||
enabled: true,
|
||||
logger: mockLogger as any,
|
||||
enableCircuitBreaker: false, // Disable for simpler testing
|
||||
sendNode: mockSendNode
|
||||
})
|
||||
})
|
||||
@@ -356,7 +354,11 @@ describe('shouldEnableUnifiedSession', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('UnifiedSessionManager with CircuitBreaker', () => {
|
||||
describe('UnifiedSessionManager — non-critical send semantics', () => {
|
||||
// The previous "with CircuitBreaker" suite is gone — telemetry no longer
|
||||
// uses a circuit breaker. Verify equivalent behavior still holds:
|
||||
// failures are swallowed (telemetry is non-critical) and don't propagate.
|
||||
|
||||
let manager: UnifiedSessionManager
|
||||
let mockSendNode: MockSendNode
|
||||
|
||||
@@ -366,7 +368,6 @@ describe('UnifiedSessionManager with CircuitBreaker', () => {
|
||||
manager = createUnifiedSessionManager({
|
||||
enabled: true,
|
||||
logger: createMockLogger() as any,
|
||||
enableCircuitBreaker: true,
|
||||
sendNode: mockSendNode
|
||||
})
|
||||
})
|
||||
@@ -375,24 +376,13 @@ describe('UnifiedSessionManager with CircuitBreaker', () => {
|
||||
manager.destroy()
|
||||
})
|
||||
|
||||
it('should work with circuit breaker enabled', async () => {
|
||||
it('should send telemetry once per call', async () => {
|
||||
await manager.send('login')
|
||||
expect(mockSendNode).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle circuit breaker failures gracefully', async () => {
|
||||
// Make send fail multiple times to trigger circuit breaker
|
||||
let callCount = 0
|
||||
mockSendNode.mockImplementation(async () => {
|
||||
callCount++
|
||||
if (callCount <= 3) {
|
||||
throw new Error(`Fail ${callCount}`)
|
||||
}
|
||||
})
|
||||
|
||||
// These should not throw even with failures
|
||||
await expect(manager.send('login')).resolves.toBeUndefined()
|
||||
await expect(manager.send('login')).resolves.toBeUndefined()
|
||||
it('should swallow send failures gracefully (telemetry is non-critical)', async () => {
|
||||
mockSendNode.mockRejectedValueOnce(new Error('boom'))
|
||||
await expect(manager.send('login')).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user