From 46eff13fa33edd13abbc2bc23b18994d3584ce87 Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Thu, 29 Jan 2026 05:01:15 -0600 Subject: [PATCH] feat(server): add circuit breaker pattern for external service calls --- .../telemetry/events-2026-01-29.ndjson | 75 +++++ server/.veritas-kanban/activity.json | 22 ++ server/.veritas-kanban/agent-status.json | 2 +- .../services/circuit-breaker.test.ts | 257 ++++++++++++++++++ 4 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 server/src/__tests__/services/circuit-breaker.test.ts diff --git a/.veritas-kanban/telemetry/events-2026-01-29.ndjson b/.veritas-kanban/telemetry/events-2026-01-29.ndjson index c0d367e1..0a33d553 100644 --- a/.veritas-kanban/telemetry/events-2026-01-29.ndjson +++ b/.veritas-kanban/telemetry/events-2026-01-29.ndjson @@ -2032,3 +2032,78 @@ {"type":"task.created","taskId":"task_20260129_ZNRHEG","status":"todo","id":"evt_kBBru2Zd2kkC","timestamp":"2026-01-29T11:00:12.510Z"} {"type":"task.created","taskId":"task_20260129_jL6MHx","status":"todo","id":"evt_GNIFCXstbW46","timestamp":"2026-01-29T11:00:12.515Z"} {"type":"task.status_changed","taskId":"task_20260129_OhJVTe","status":"done","previousStatus":"in-progress","id":"evt_6jBQKWsl7y9z","timestamp":"2026-01-29T11:00:22.085Z"} +{"type":"task.status_changed","taskId":"task_20260126_legacy1","status":"blocked","previousStatus":"review","id":"evt_CQqZJTtApL7P","timestamp":"2026-01-29T11:00:56.120Z"} +{"type":"task.created","taskId":"task_20260129_kVKZQH","status":"todo","id":"evt_ey0OJo99yktp","timestamp":"2026-01-29T11:00:56.154Z"} +{"type":"task.created","taskId":"task_20260129_PQKgJH","status":"todo","id":"evt_OTEatPZtWezB","timestamp":"2026-01-29T11:00:56.232Z"} +{"type":"task.status_changed","taskId":"task_20260129_PQKgJH","status":"in-progress","previousStatus":"todo","id":"evt_J3oTbFA9cC3V","timestamp":"2026-01-29T11:00:56.236Z"} +{"type":"task.created","taskId":"task_20260129_dn_MnL","status":"todo","id":"evt_iQtKb68u3Evq","timestamp":"2026-01-29T11:00:56.240Z"} +{"type":"task.status_changed","taskId":"task_20260129_dn_MnL","status":"blocked","previousStatus":"todo","id":"evt_GTLMjjr8PfDz","timestamp":"2026-01-29T11:00:56.242Z"} +{"type":"task.created","taskId":"task_20260129_U-fU9R","status":"todo","id":"evt_CkJF0bib9K6e","timestamp":"2026-01-29T11:00:56.244Z"} +{"type":"task.status_changed","taskId":"task_20260129_U-fU9R","status":"done","previousStatus":"todo","id":"evt_APBEHUbClnp2","timestamp":"2026-01-29T11:00:56.249Z"} +{"type":"task.created","taskId":"task_20260129_Bm9fj-","project":"my-project","status":"todo","id":"evt_aEkpBmKJMFHb","timestamp":"2026-01-29T11:00:56.254Z"} +{"type":"task.status_changed","taskId":"task_20260126_idem1","status":"blocked","previousStatus":"review","id":"evt_p5p_mXQfSWMj","timestamp":"2026-01-29T11:00:56.262Z"} +{"type":"task.created","taskId":"task_20260129_y7LoIv","status":"todo","id":"evt_A6GNL9iVWjgT","timestamp":"2026-01-29T11:00:56.266Z"} +{"type":"task.status_changed","taskId":"task_20260126_multi1","status":"blocked","previousStatus":"review","id":"evt_qyJN2mmC7aaC","timestamp":"2026-01-29T11:00:56.294Z"} +{"type":"task.status_changed","taskId":"task_20260126_multi2","status":"blocked","previousStatus":"review","id":"evt_XccRmveS4rbh","timestamp":"2026-01-29T11:00:56.297Z"} +{"type":"task.created","taskId":"task_20260129_QKi7Y7","status":"todo","id":"evt_HP3Fe9ha9Je6","timestamp":"2026-01-29T11:00:56.301Z"} +{"type":"task.created","taskId":"task_20260129_pr4stF","status":"todo","id":"evt_7So3GHnLsekk","timestamp":"2026-01-29T11:00:56.312Z"} +{"type":"task.created","taskId":"task_20260129_eAMcXG","status":"todo","id":"evt_N3jzGuIwUnyn","timestamp":"2026-01-29T11:00:56.326Z"} +{"type":"task.status_changed","taskId":"task_20260129_pr4stF","status":"in-progress","previousStatus":"todo","id":"evt_jpP52g3he8ma","timestamp":"2026-01-29T11:00:56.326Z"} +{"type":"task.created","taskId":"task_20260129_WfN5z-","status":"todo","id":"evt_AjVxN_Y-qvpD","timestamp":"2026-01-29T11:00:56.343Z"} +{"type":"task.created","taskId":"task_20260129_EWdbRj","status":"todo","id":"evt_gYCLMX24kJLJ","timestamp":"2026-01-29T11:00:56.353Z"} +{"type":"task.created","taskId":"task_20260129_q9N2qm","status":"todo","id":"evt_q8Dihc8R_eHr","timestamp":"2026-01-29T11:00:56.367Z"} +{"type":"task.created","taskId":"task_20260129_yD_HXl","status":"todo","id":"evt_ztzR9Cn-w2r6","timestamp":"2026-01-29T11:00:56.401Z"} +{"type":"task.restored","taskId":"task_20260126_arch1","status":"done","id":"evt__i4TrXdBBfPQ","timestamp":"2026-01-29T11:00:56.402Z"} +{"type":"task.status_changed","taskId":"task_20260126_arch1","status":"blocked","previousStatus":"done","id":"evt_e33eL0oftCf1","timestamp":"2026-01-29T11:00:56.404Z"} +{"type":"task.archived","taskId":"task_20260126_arch1","status":"blocked","id":"evt_oU0YsfSu7Lhf","timestamp":"2026-01-29T11:00:56.407Z"} +{"type":"task.created","taskId":"task_20260129_R162ak","status":"todo","id":"evt_lkjqAZaPOa1N","timestamp":"2026-01-29T11:00:56.412Z"} +{"type":"task.archived","taskId":"task_20260129_R162ak","status":"todo","id":"evt_zsSFYMoLnTt9","timestamp":"2026-01-29T11:00:56.417Z"} +{"type":"task.created","taskId":"task_20260129_bNsxNA","status":"todo","id":"evt_m1heK6118WnJ","timestamp":"2026-01-29T11:00:56.418Z"} +{"type":"task.created","taskId":"task_20260129_hAAOmh","status":"todo","id":"evt_9MsRYOV1q0s2","timestamp":"2026-01-29T11:00:56.432Z"} +{"type":"task.created","taskId":"task_20260129_OPqfGP","status":"todo","id":"evt_dIlJAf6ZB2qq","timestamp":"2026-01-29T11:00:56.433Z"} +{"type":"task.created","taskId":"task_20260129_H7aQA0","status":"todo","id":"evt_vUe7VXFP8uYl","timestamp":"2026-01-29T11:00:56.443Z"} +{"type":"task.created","taskId":"task_20260129_u5HdwO","status":"todo","id":"evt_tY1K65I3WO67","timestamp":"2026-01-29T11:00:56.443Z"} +{"type":"task.created","taskId":"task_20260129_aGas7T","status":"todo","id":"evt_XHZ7gDgYy7XF","timestamp":"2026-01-29T11:00:56.448Z"} +{"type":"task.created","taskId":"task_20260129_bJuCy3","status":"todo","id":"evt_vmlGnZhbP2Gj","timestamp":"2026-01-29T11:00:56.461Z"} +{"type":"task.created","taskId":"task_20260129_6L14q9","status":"todo","id":"evt_FpSwRCK-iDC8","timestamp":"2026-01-29T11:00:56.465Z"} +{"type":"task.created","taskId":"task_20260129_JvpxwV","status":"todo","id":"evt_WxTd1dPjXhyI","timestamp":"2026-01-29T11:00:56.472Z"} +{"type":"task.created","taskId":"task_20260129_Gyda-V","status":"todo","id":"evt_1JTRViR6rgak","timestamp":"2026-01-29T11:00:56.483Z"} +{"type":"task.created","taskId":"task_20260129_yE-d91","status":"todo","id":"evt_kMR87j8VKAdv","timestamp":"2026-01-29T11:00:56.495Z"} +{"type":"task.created","taskId":"task_20260129_7g4JMJ","status":"todo","id":"evt_p-X2wuQaAYBN","timestamp":"2026-01-29T11:00:56.510Z"} +{"type":"task.created","taskId":"task_20260129_94CfGH","status":"todo","id":"evt_y-Q9Vr0Au5vB","timestamp":"2026-01-29T11:00:56.513Z"} +{"type":"task.created","taskId":"task_20260129_QkeqLb","status":"todo","id":"evt_FW5rfCJhLT6j","timestamp":"2026-01-29T11:00:56.514Z"} +{"type":"task.created","taskId":"task_20260129_lWzYdy","status":"todo","id":"evt_CB0EhNXU5XsR","timestamp":"2026-01-29T11:00:56.520Z"} +{"type":"task.created","taskId":"task_20260129_ZtifT5","status":"todo","id":"evt_q1PcasdshEJW","timestamp":"2026-01-29T11:00:56.523Z"} +{"type":"task.created","taskId":"task_20260129_4A5NEH","status":"todo","id":"evt_DNBa-xrgQC2N","timestamp":"2026-01-29T11:00:56.531Z"} +{"type":"task.created","taskId":"task_20260129_s-2_jQ","status":"todo","id":"evt_ZDX9Ckue_-Vu","timestamp":"2026-01-29T11:00:56.534Z"} +{"type":"task.created","taskId":"task_20260129_g6qIeS","status":"todo","id":"evt_bkT6Eu_4h3LT","timestamp":"2026-01-29T11:00:56.540Z"} +{"type":"task.created","taskId":"task_20260129_YEETDq","status":"todo","id":"evt_sQPzgfs6upI5","timestamp":"2026-01-29T11:00:56.549Z"} +{"type":"task.created","taskId":"task_20260129_NrzxJ8","status":"todo","id":"evt_GCGzWG8LNFnS","timestamp":"2026-01-29T11:00:56.568Z"} +{"type":"task.created","taskId":"task_20260129_1oDC5J","project":"project-a","status":"todo","id":"evt_cigEwGRW3BUk","timestamp":"2026-01-29T11:00:56.591Z"} +{"type":"task.created","taskId":"task_20260129_AevP9H","project":"project-a","status":"todo","id":"evt_zNlfaSjvuo0i","timestamp":"2026-01-29T11:00:56.598Z"} +{"type":"task.created","taskId":"task_20260129_RUlJt5","project":"project-b","status":"todo","id":"evt_71JOaR6PWgbX","timestamp":"2026-01-29T11:00:56.600Z"} +{"type":"task.created","taskId":"task_20260129_3uEvat","status":"todo","id":"evt_BN-Fo4OpIcHX","timestamp":"2026-01-29T11:00:56.616Z"} +{"type":"task.created","taskId":"task_20260129_xKAZY_","status":"todo","id":"evt_jrF4fSpbqBOi","timestamp":"2026-01-29T11:00:56.676Z"} +{"type":"task.created","taskId":"task_20260129_u4Dzc5","status":"todo","id":"evt_VWmkHL1WV62K","timestamp":"2026-01-29T11:00:56.711Z"} +{"type":"task.created","taskId":"task_20260129_Ib9_6x","status":"todo","id":"evt_Yzj2LO5ZR-Dg","timestamp":"2026-01-29T11:00:56.750Z"} +{"type":"task.created","taskId":"task_20260129_yG93b_","status":"todo","id":"evt_zXk8B7ZFUp6M","timestamp":"2026-01-29T11:00:56.766Z"} +{"type":"task.created","taskId":"task_20260129_c9pqK6","status":"todo","id":"evt_rG6RE51lO0mL","timestamp":"2026-01-29T11:00:56.774Z"} +{"type":"task.created","taskId":"task_20260129_uHAywG","status":"todo","id":"evt_tgXk3Mg92_N6","timestamp":"2026-01-29T11:00:56.775Z"} +{"type":"task.created","taskId":"task_20260129_dhISci","status":"todo","id":"evt_B7z8ObCO4KSC","timestamp":"2026-01-29T11:00:56.777Z"} +{"type":"task.created","taskId":"task_20260129_1bbok4","status":"todo","id":"evt_typW7orZgrYd","timestamp":"2026-01-29T11:00:56.787Z"} +{"type":"task.created","taskId":"task_20260129_1KZUqq","status":"todo","id":"evt_MuweL6lJGKEU","timestamp":"2026-01-29T11:00:56.793Z"} +{"type":"task.created","taskId":"task_20260129_q8sk48","project":"test-project","status":"todo","id":"evt_wPOPdWRgS1Es","timestamp":"2026-01-29T11:00:56.802Z"} +{"type":"task.created","taskId":"task_20260129_HHGl32","status":"todo","id":"evt_o-MJFiMpiCor","timestamp":"2026-01-29T11:00:56.808Z"} +{"type":"task.created","taskId":"task_20260129_Vr9NzL","status":"todo","id":"evt_mKX8ouXdJ2yj","timestamp":"2026-01-29T11:00:56.810Z"} +{"type":"task.created","taskId":"task_20260129_w-4r4c","status":"todo","id":"evt_TeDTjQZOlbO3","timestamp":"2026-01-29T11:00:56.818Z"} +{"type":"task.created","taskId":"task_20260129_rILlN4","status":"todo","id":"evt_jftUzvrGjmIu","timestamp":"2026-01-29T11:00:56.821Z"} +{"type":"task.status_changed","taskId":"task_20260129_rILlN4","status":"in-progress","previousStatus":"todo","id":"evt_9igpivD0xmsi","timestamp":"2026-01-29T11:00:56.827Z"} +{"type":"task.created","taskId":"task_20260129_RQNnh2","status":"todo","id":"evt_aC3ywX8x5XTN","timestamp":"2026-01-29T11:00:56.828Z"} +{"type":"task.created","taskId":"task_20260129_xDg0VP","status":"todo","id":"evt_As9Yr6bqHAWy","timestamp":"2026-01-29T11:00:56.840Z"} +{"type":"task.created","taskId":"task_20260129_sIOoRm","status":"todo","id":"evt_EraFeDTGhavW","timestamp":"2026-01-29T11:00:56.840Z"} +{"type":"task.created","taskId":"task_20260129_bkFS8v","status":"todo","id":"evt_IROh2QX7OAUA","timestamp":"2026-01-29T11:00:56.864Z"} +{"type":"task.created","taskId":"task_20260129_19yQWD","status":"todo","id":"evt_0IpaAlw_FgT5","timestamp":"2026-01-29T11:00:56.868Z"} +{"type":"task.created","taskId":"task_20260129_Zsxd7t","status":"todo","id":"evt_dFVpxVtc3SCp","timestamp":"2026-01-29T11:00:56.946Z"} +{"type":"task.created","taskId":"task_20260129_ocqa1q","status":"todo","id":"evt_UtAfutoL9Czz","timestamp":"2026-01-29T11:00:56.948Z"} +{"type":"task.created","taskId":"task_20260129_ak_EdA","status":"todo","id":"evt_yPmLdjWS9sHP","timestamp":"2026-01-29T11:00:56.952Z"} +{"type":"task.status_changed","taskId":"task_20260129_g7GLeb","status":"done","previousStatus":"in-progress","id":"evt_HHiPihFMG5uE","timestamp":"2026-01-29T11:01:11.941Z"} diff --git a/server/.veritas-kanban/activity.json b/server/.veritas-kanban/activity.json index 7269a42f..13ab0c13 100644 --- a/server/.veritas-kanban/activity.json +++ b/server/.veritas-kanban/activity.json @@ -1,4 +1,26 @@ [ + { + "id": "activity_1769684471958_wz7v725l5", + "type": "comment_added", + "taskId": "task_20260129_g7GLeb", + "taskTitle": "[v1.1] STABILITY: Add circuit breaker pattern for external service calls", + "details": { + "author": "Veritas", + "preview": "Implemented circuit breaker pattern: CircuitBreake..." + }, + "timestamp": "2026-01-29T11:01:11.958Z" + }, + { + "id": "activity_1769684471941_y1ec68g6u", + "type": "status_changed", + "taskId": "task_20260129_g7GLeb", + "taskTitle": "[v1.1] STABILITY: Add circuit breaker pattern for external service calls", + "details": { + "from": "in-progress", + "status": "done" + }, + "timestamp": "2026-01-29T11:01:11.941Z" + }, { "id": "activity_1769684429997_a0gnv72cw", "type": "comment_added", diff --git a/server/.veritas-kanban/agent-status.json b/server/.veritas-kanban/agent-status.json index 6cf06e4a..0ac3749b 100644 --- a/server/.veritas-kanban/agent-status.json +++ b/server/.veritas-kanban/agent-status.json @@ -1,5 +1,5 @@ { "status": "idle", "subAgentCount": 0, - "lastUpdated": "2026-01-29T11:00:12.397Z" + "lastUpdated": "2026-01-29T11:00:57.529Z" } diff --git a/server/src/__tests__/services/circuit-breaker.test.ts b/server/src/__tests__/services/circuit-breaker.test.ts new file mode 100644 index 00000000..9ff70737 --- /dev/null +++ b/server/src/__tests__/services/circuit-breaker.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { CircuitBreaker, CircuitOpenError } from '../../services/circuit-breaker.js'; + +// Silence the pino logger during tests +vi.mock('../../lib/logger.js', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +describe('CircuitBreaker', () => { + let breaker: CircuitBreaker; + + beforeEach(() => { + vi.useFakeTimers(); + breaker = new CircuitBreaker({ + name: 'test', + failureThreshold: 3, + resetTimeout: 10_000, + monitorWindow: 30_000, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // ----------------------------------------------- + // Basic behavior + // ----------------------------------------------- + + it('starts in closed state', () => { + expect(breaker.state).toBe('closed'); + }); + + it('passes through successful calls in closed state', async () => { + const result = await breaker.execute(() => Promise.resolve('ok')); + expect(result).toBe('ok'); + expect(breaker.state).toBe('closed'); + }); + + it('passes through failures in closed state (below threshold)', async () => { + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow('fail'); + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow('fail'); + // 2 failures, threshold is 3 → still closed + expect(breaker.state).toBe('closed'); + }); + + // ----------------------------------------------- + // Closed → Open transition + // ----------------------------------------------- + + it('transitions to open after reaching failure threshold', async () => { + for (let i = 0; i < 3; i++) { + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow( + 'fail' + ); + } + expect(breaker.state).toBe('open'); + }); + + // ----------------------------------------------- + // Open state behavior + // ----------------------------------------------- + + it('rejects immediately when open', async () => { + // Trip the circuit + for (let i = 0; i < 3; i++) { + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow(); + } + expect(breaker.state).toBe('open'); + + // Subsequent calls should be rejected without executing the function + const fn = vi.fn(() => Promise.resolve('should not run')); + await expect(breaker.execute(fn)).rejects.toThrow(CircuitOpenError); + expect(fn).not.toHaveBeenCalled(); + }); + + it('CircuitOpenError has correct properties', async () => { + for (let i = 0; i < 3; i++) { + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow(); + } + + try { + await breaker.execute(() => Promise.resolve('nope')); + expect.fail('Should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CircuitOpenError); + const coe = err as CircuitOpenError; + expect(coe.circuitName).toBe('test'); + expect(coe.nextAttempt).toBeInstanceOf(Date); + } + }); + + // ----------------------------------------------- + // Open → Half-open transition + // ----------------------------------------------- + + it('transitions to half-open after reset timeout', async () => { + // Trip the circuit + for (let i = 0; i < 3; i++) { + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow(); + } + expect(breaker.state).toBe('open'); + + // Advance past the reset timeout + vi.advanceTimersByTime(10_001); + expect(breaker.state).toBe('half-open'); + }); + + // ----------------------------------------------- + // Half-open: allows one request + // ----------------------------------------------- + + it('allows one request through in half-open state', async () => { + // Trip and wait for half-open + for (let i = 0; i < 3; i++) { + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow(); + } + vi.advanceTimersByTime(10_001); + expect(breaker.state).toBe('half-open'); + + const result = await breaker.execute(() => Promise.resolve('recovered')); + expect(result).toBe('recovered'); + }); + + // ----------------------------------------------- + // Half-open → Closed (success) + // ----------------------------------------------- + + it('closes the circuit when half-open request succeeds', async () => { + for (let i = 0; i < 3; i++) { + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow(); + } + vi.advanceTimersByTime(10_001); + expect(breaker.state).toBe('half-open'); + + await breaker.execute(() => Promise.resolve('ok')); + expect(breaker.state).toBe('closed'); + + // Should now allow normal traffic again + const result = await breaker.execute(() => Promise.resolve('normal')); + expect(result).toBe('normal'); + }); + + // ----------------------------------------------- + // Half-open → Open (failure) + // ----------------------------------------------- + + it('reopens the circuit when half-open request fails', async () => { + for (let i = 0; i < 3; i++) { + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow(); + } + vi.advanceTimersByTime(10_001); + expect(breaker.state).toBe('half-open'); + + await expect(breaker.execute(() => Promise.reject(new Error('still failing')))).rejects.toThrow( + 'still failing' + ); + expect(breaker.state).toBe('open'); + }); + + // ----------------------------------------------- + // Sliding window — old failures expire + // ----------------------------------------------- + + it('resets old failures outside the monitor window', async () => { + // 2 failures + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow(); + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow(); + expect(breaker.state).toBe('closed'); + + // Advance past the monitor window so those 2 failures expire + vi.advanceTimersByTime(31_000); + + // One more failure — total recent = 1, not 3 + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow(); + expect(breaker.state).toBe('closed'); + }); + + // ----------------------------------------------- + // Concurrent half-open — only one passes + // ----------------------------------------------- + + it('only allows one concurrent request in half-open state', async () => { + // Trip the circuit + for (let i = 0; i < 3; i++) { + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow(); + } + vi.advanceTimersByTime(10_001); + expect(breaker.state).toBe('half-open'); + + // Create a slow promise that we control + let resolveFirst!: (value: string) => void; + const slowPromise = new Promise((resolve) => { + resolveFirst = resolve; + }); + + // Start first request (this one should get through) + const firstCall = breaker.execute(() => slowPromise); + + // Second concurrent request should be rejected + await expect(breaker.execute(() => Promise.resolve('second'))).rejects.toThrow( + CircuitOpenError + ); + + // Resolve the first request + resolveFirst('first'); + const result = await firstCall; + expect(result).toBe('first'); + expect(breaker.state).toBe('closed'); + }); + + // ----------------------------------------------- + // Status reporting + // ----------------------------------------------- + + it('reports correct status in closed state', () => { + const status = breaker.getStatus(); + expect(status.state).toBe('closed'); + expect(status.failures).toBe(0); + expect(status.lastFailure).toBeNull(); + expect(status.nextAttempt).toBeNull(); + }); + + it('reports correct status in open state', async () => { + for (let i = 0; i < 3; i++) { + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow(); + } + + const status = breaker.getStatus(); + expect(status.state).toBe('open'); + expect(status.failures).toBe(3); + expect(status.lastFailure).not.toBeNull(); + expect(status.nextAttempt).not.toBeNull(); + }); + + // ----------------------------------------------- + // Manual reset + // ----------------------------------------------- + + it('can be manually reset to closed', async () => { + for (let i = 0; i < 3; i++) { + await expect(breaker.execute(() => Promise.reject(new Error('fail')))).rejects.toThrow(); + } + expect(breaker.state).toBe('open'); + + breaker.reset(); + expect(breaker.state).toBe('closed'); + + const result = await breaker.execute(() => Promise.resolve('after reset')); + expect(result).toBe('after reset'); + }); +});