@supaku/agentfactory-linear 0.7.18 → 0.7.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Circuit Breaker for Linear API calls
3
+ *
4
+ * Prevents wasting rate limit quota on requests that are guaranteed to fail
5
+ * (e.g., expired OAuth tokens, revoked access). Implements the standard
6
+ * closed → open → half-open state machine.
7
+ *
8
+ * State machine:
9
+ * closed → all calls proceed; auth failures increment counter;
10
+ * at threshold → open
11
+ * open → all calls throw CircuitOpenError immediately (zero quota);
12
+ * after resetTimeoutMs → half-open
13
+ * half-open → one probe call allowed; success → closed;
14
+ * failure → open (with exponential backoff on reset timeout)
15
+ */
16
+ import { CircuitOpenError } from './errors.js';
17
+ import type { CircuitBreakerConfig, CircuitBreakerStrategy } from './types.js';
18
+ export type CircuitState = 'closed' | 'open' | 'half-open';
19
+ export declare const DEFAULT_CIRCUIT_BREAKER_CONFIG: Required<CircuitBreakerConfig>;
20
+ export declare class CircuitBreaker implements CircuitBreakerStrategy {
21
+ private _state;
22
+ private consecutiveFailures;
23
+ private openedAt;
24
+ private currentResetTimeoutMs;
25
+ private probeInFlight;
26
+ private readonly config;
27
+ constructor(config?: Partial<CircuitBreakerConfig>);
28
+ get state(): CircuitState;
29
+ /**
30
+ * Check if a call is allowed to proceed.
31
+ * In half-open state, only one probe call is allowed at a time.
32
+ */
33
+ canProceed(): boolean;
34
+ /**
35
+ * Record a successful API call. Resets the circuit to closed.
36
+ */
37
+ recordSuccess(): void;
38
+ /**
39
+ * Record an auth failure. May trip the circuit to open.
40
+ * Called after isAuthError() returns true, so the error is already vetted.
41
+ */
42
+ recordAuthFailure(_statusCode?: number): void;
43
+ /**
44
+ * Check if an error is an auth/rate-limit error that should count as a circuit failure.
45
+ *
46
+ * Detects:
47
+ * - HTTP status codes in authErrorCodes (400, 401, 403)
48
+ * - Linear GraphQL RATELIMITED error code in response body
49
+ * - Linear SDK error objects with nested error details
50
+ */
51
+ isAuthError(error: unknown): boolean;
52
+ /**
53
+ * Extract the status code from an auth error, or 0 if not determinable.
54
+ */
55
+ extractStatusCode(error: unknown): number;
56
+ /**
57
+ * Reset the circuit breaker to closed state.
58
+ */
59
+ reset(): void;
60
+ /**
61
+ * Get diagnostic info for logging/monitoring.
62
+ */
63
+ getStatus(): {
64
+ state: CircuitState;
65
+ consecutiveFailures: number;
66
+ currentResetTimeoutMs: number;
67
+ msSinceOpened: number | null;
68
+ };
69
+ /**
70
+ * Create a CircuitOpenError with current diagnostic info.
71
+ */
72
+ createOpenError(): CircuitOpenError;
73
+ private trip;
74
+ private shouldTransitionToHalfOpen;
75
+ }
76
+ //# sourceMappingURL=circuit-breaker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"circuit-breaker.d.ts","sourceRoot":"","sources":["../../src/circuit-breaker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAC9C,OAAO,KAAK,EAAE,oBAAoB,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAA;AAE9E,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,MAAM,GAAG,WAAW,CAAA;AAE1D,eAAO,MAAM,8BAA8B,EAAE,QAAQ,CAAC,oBAAoB,CAMzE,CAAA;AAED,qBAAa,cAAe,YAAW,sBAAsB;IAC3D,OAAO,CAAC,MAAM,CAAyB;IACvC,OAAO,CAAC,mBAAmB,CAAI;IAC/B,OAAO,CAAC,QAAQ,CAAI;IACpB,OAAO,CAAC,qBAAqB,CAAQ;IACrC,OAAO,CAAC,aAAa,CAAQ;IAC7B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgC;gBAE3C,MAAM,CAAC,EAAE,OAAO,CAAC,oBAAoB,CAAC;IAKlD,IAAI,KAAK,IAAI,YAAY,CAOxB;IAED;;;OAGG;IACH,UAAU,IAAI,OAAO;IAgBrB;;OAEG;IACH,aAAa,IAAI,IAAI;IASrB;;;OAGG;IACH,iBAAiB,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI;IAmB7C;;;;;;;OAOG;IACH,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO;IAyBpC;;OAEG;IACH,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM;IAKzC;;OAEG;IACH,KAAK,IAAI,IAAI;IAOb;;OAEG;IACH,SAAS,IAAI;QACX,KAAK,EAAE,YAAY,CAAA;QACnB,mBAAmB,EAAE,MAAM,CAAA;QAC3B,qBAAqB,EAAE,MAAM,CAAA;QAC7B,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;KAC7B;IASD;;OAEG;IACH,eAAe,IAAI,gBAAgB;IAgBnC,OAAO,CAAC,IAAI;IAKZ,OAAO,CAAC,0BAA0B;CAGnC"}
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Circuit Breaker for Linear API calls
3
+ *
4
+ * Prevents wasting rate limit quota on requests that are guaranteed to fail
5
+ * (e.g., expired OAuth tokens, revoked access). Implements the standard
6
+ * closed → open → half-open state machine.
7
+ *
8
+ * State machine:
9
+ * closed → all calls proceed; auth failures increment counter;
10
+ * at threshold → open
11
+ * open → all calls throw CircuitOpenError immediately (zero quota);
12
+ * after resetTimeoutMs → half-open
13
+ * half-open → one probe call allowed; success → closed;
14
+ * failure → open (with exponential backoff on reset timeout)
15
+ */
16
+ import { CircuitOpenError } from './errors.js';
17
+ export const DEFAULT_CIRCUIT_BREAKER_CONFIG = {
18
+ failureThreshold: 2,
19
+ resetTimeoutMs: 60_000,
20
+ maxResetTimeoutMs: 300_000,
21
+ backoffMultiplier: 2,
22
+ authErrorCodes: [400, 401, 403],
23
+ };
24
+ export class CircuitBreaker {
25
+ _state = 'closed';
26
+ consecutiveFailures = 0;
27
+ openedAt = 0;
28
+ currentResetTimeoutMs;
29
+ probeInFlight = false;
30
+ config;
31
+ constructor(config) {
32
+ this.config = { ...DEFAULT_CIRCUIT_BREAKER_CONFIG, ...config };
33
+ this.currentResetTimeoutMs = this.config.resetTimeoutMs;
34
+ }
35
+ get state() {
36
+ // Check if open circuit should transition to half-open
37
+ if (this._state === 'open' && this.shouldTransitionToHalfOpen()) {
38
+ this._state = 'half-open';
39
+ this.probeInFlight = false;
40
+ }
41
+ return this._state;
42
+ }
43
+ /**
44
+ * Check if a call is allowed to proceed.
45
+ * In half-open state, only one probe call is allowed at a time.
46
+ */
47
+ canProceed() {
48
+ const currentState = this.state; // triggers open → half-open check
49
+ switch (currentState) {
50
+ case 'closed':
51
+ return true;
52
+ case 'open':
53
+ return false;
54
+ case 'half-open':
55
+ // Allow exactly one probe call
56
+ if (this.probeInFlight)
57
+ return false;
58
+ this.probeInFlight = true;
59
+ return true;
60
+ }
61
+ }
62
+ /**
63
+ * Record a successful API call. Resets the circuit to closed.
64
+ */
65
+ recordSuccess() {
66
+ this.consecutiveFailures = 0;
67
+ this.probeInFlight = false;
68
+ if (this._state !== 'closed') {
69
+ this._state = 'closed';
70
+ this.currentResetTimeoutMs = this.config.resetTimeoutMs;
71
+ }
72
+ }
73
+ /**
74
+ * Record an auth failure. May trip the circuit to open.
75
+ * Called after isAuthError() returns true, so the error is already vetted.
76
+ */
77
+ recordAuthFailure(_statusCode) {
78
+ this.probeInFlight = false;
79
+ this.consecutiveFailures++;
80
+ if (this._state === 'half-open') {
81
+ // Probe failed — reopen with exponential backoff
82
+ this.trip();
83
+ this.currentResetTimeoutMs = Math.min(this.currentResetTimeoutMs * this.config.backoffMultiplier, this.config.maxResetTimeoutMs);
84
+ return;
85
+ }
86
+ if (this.consecutiveFailures >= this.config.failureThreshold) {
87
+ this.trip();
88
+ }
89
+ }
90
+ /**
91
+ * Check if an error is an auth/rate-limit error that should count as a circuit failure.
92
+ *
93
+ * Detects:
94
+ * - HTTP status codes in authErrorCodes (400, 401, 403)
95
+ * - Linear GraphQL RATELIMITED error code in response body
96
+ * - Linear SDK error objects with nested error details
97
+ */
98
+ isAuthError(error) {
99
+ if (typeof error !== 'object' || error === null)
100
+ return false;
101
+ const err = error;
102
+ // Check HTTP status code
103
+ const statusCode = extractStatusCode(err);
104
+ if (statusCode !== null && this.config.authErrorCodes.includes(statusCode)) {
105
+ return true;
106
+ }
107
+ // Check for Linear GraphQL RATELIMITED error
108
+ if (isGraphQLRateLimited(err)) {
109
+ return true;
110
+ }
111
+ // Check error message for known auth failure patterns
112
+ const message = err.message ?? '';
113
+ if (/access denied|unauthorized|forbidden/i.test(message)) {
114
+ return true;
115
+ }
116
+ return false;
117
+ }
118
+ /**
119
+ * Extract the status code from an auth error, or 0 if not determinable.
120
+ */
121
+ extractStatusCode(error) {
122
+ if (typeof error !== 'object' || error === null)
123
+ return 0;
124
+ return extractStatusCode(error) ?? 0;
125
+ }
126
+ /**
127
+ * Reset the circuit breaker to closed state.
128
+ */
129
+ reset() {
130
+ this._state = 'closed';
131
+ this.consecutiveFailures = 0;
132
+ this.probeInFlight = false;
133
+ this.currentResetTimeoutMs = this.config.resetTimeoutMs;
134
+ }
135
+ /**
136
+ * Get diagnostic info for logging/monitoring.
137
+ */
138
+ getStatus() {
139
+ return {
140
+ state: this.state,
141
+ consecutiveFailures: this.consecutiveFailures,
142
+ currentResetTimeoutMs: this.currentResetTimeoutMs,
143
+ msSinceOpened: this.openedAt > 0 ? Date.now() - this.openedAt : null,
144
+ };
145
+ }
146
+ /**
147
+ * Create a CircuitOpenError with current diagnostic info.
148
+ */
149
+ createOpenError() {
150
+ const timeRemaining = Math.max(0, this.currentResetTimeoutMs - (Date.now() - this.openedAt));
151
+ return new CircuitOpenError(`Circuit breaker is open — Linear API calls blocked for ${Math.ceil(timeRemaining / 1000)}s. ` +
152
+ `${this.consecutiveFailures} consecutive auth failures detected.`, timeRemaining);
153
+ }
154
+ // ---------------------------------------------------------------------------
155
+ // Private
156
+ // ---------------------------------------------------------------------------
157
+ trip() {
158
+ this._state = 'open';
159
+ this.openedAt = Date.now();
160
+ }
161
+ shouldTransitionToHalfOpen() {
162
+ return Date.now() - this.openedAt >= this.currentResetTimeoutMs;
163
+ }
164
+ }
165
+ // ---------------------------------------------------------------------------
166
+ // Helpers
167
+ // ---------------------------------------------------------------------------
168
+ /**
169
+ * Extract HTTP status code from various error shapes
170
+ */
171
+ function extractStatusCode(err) {
172
+ // Direct status/statusCode property
173
+ if (typeof err.status === 'number')
174
+ return err.status;
175
+ if (typeof err.statusCode === 'number')
176
+ return err.statusCode;
177
+ // Nested in response
178
+ const response = err.response;
179
+ if (response) {
180
+ if (typeof response.status === 'number')
181
+ return response.status;
182
+ if (typeof response.statusCode === 'number')
183
+ return response.statusCode;
184
+ }
185
+ return null;
186
+ }
187
+ /**
188
+ * Check if the error contains a Linear GraphQL RATELIMITED error code.
189
+ *
190
+ * Linear returns HTTP 200 with a GraphQL error body when rate-limited:
191
+ * { errors: [{ extensions: { code: 'RATELIMITED' } }] }
192
+ */
193
+ function isGraphQLRateLimited(err) {
194
+ // Check error.extensions.code directly (Linear SDK error shape)
195
+ const extensions = err.extensions;
196
+ if (extensions?.code === 'RATELIMITED')
197
+ return true;
198
+ // Check nested errors array (raw GraphQL response shape)
199
+ const errors = err.errors;
200
+ if (Array.isArray(errors)) {
201
+ for (const gqlError of errors) {
202
+ const ext = gqlError.extensions;
203
+ if (ext?.code === 'RATELIMITED')
204
+ return true;
205
+ }
206
+ }
207
+ // Check response body for GraphQL errors
208
+ const response = err.response;
209
+ if (response) {
210
+ const body = response.body;
211
+ const data = response.data;
212
+ const target = body ?? data;
213
+ if (target) {
214
+ const bodyErrors = target.errors;
215
+ if (Array.isArray(bodyErrors)) {
216
+ for (const gqlError of bodyErrors) {
217
+ const ext = gqlError.extensions;
218
+ if (ext?.code === 'RATELIMITED')
219
+ return true;
220
+ }
221
+ }
222
+ }
223
+ }
224
+ // Check error message as last resort
225
+ const message = err.message ?? '';
226
+ if (message.includes('RATELIMITED'))
227
+ return true;
228
+ return false;
229
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=circuit-breaker.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"circuit-breaker.test.d.ts","sourceRoot":"","sources":["../../src/circuit-breaker.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,292 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { CircuitBreaker, DEFAULT_CIRCUIT_BREAKER_CONFIG } from './circuit-breaker.js';
3
+ import { CircuitOpenError } from './errors.js';
4
+ describe('CircuitBreaker', () => {
5
+ beforeEach(() => {
6
+ vi.useFakeTimers();
7
+ });
8
+ afterEach(() => {
9
+ vi.useRealTimers();
10
+ });
11
+ // ========================================================================
12
+ // Construction & defaults
13
+ // ========================================================================
14
+ it('starts in closed state', () => {
15
+ const cb = new CircuitBreaker();
16
+ expect(cb.state).toBe('closed');
17
+ });
18
+ it('uses default config when none provided', () => {
19
+ const cb = new CircuitBreaker();
20
+ const status = cb.getStatus();
21
+ expect(status.state).toBe('closed');
22
+ expect(status.consecutiveFailures).toBe(0);
23
+ expect(status.currentResetTimeoutMs).toBe(DEFAULT_CIRCUIT_BREAKER_CONFIG.resetTimeoutMs);
24
+ });
25
+ it('accepts custom config', () => {
26
+ const cb = new CircuitBreaker({ failureThreshold: 5, resetTimeoutMs: 30_000 });
27
+ // Trip it 4 times — should still be closed
28
+ for (let i = 0; i < 4; i++) {
29
+ cb.recordAuthFailure(401);
30
+ }
31
+ expect(cb.state).toBe('closed');
32
+ // 5th failure trips it
33
+ cb.recordAuthFailure(401);
34
+ expect(cb.state).toBe('open');
35
+ });
36
+ // ========================================================================
37
+ // canProceed
38
+ // ========================================================================
39
+ it('allows calls when closed', () => {
40
+ const cb = new CircuitBreaker();
41
+ expect(cb.canProceed()).toBe(true);
42
+ });
43
+ it('blocks calls when open', () => {
44
+ const cb = new CircuitBreaker({ failureThreshold: 1 });
45
+ cb.recordAuthFailure(401);
46
+ expect(cb.state).toBe('open');
47
+ expect(cb.canProceed()).toBe(false);
48
+ });
49
+ it('allows one probe call when half-open', () => {
50
+ const cb = new CircuitBreaker({ failureThreshold: 1, resetTimeoutMs: 1000 });
51
+ cb.recordAuthFailure(401);
52
+ expect(cb.state).toBe('open');
53
+ // Advance past reset timeout
54
+ vi.advanceTimersByTime(1001);
55
+ expect(cb.state).toBe('half-open');
56
+ // First call should be allowed (probe)
57
+ expect(cb.canProceed()).toBe(true);
58
+ // Second call should be blocked (probe already in-flight)
59
+ expect(cb.canProceed()).toBe(false);
60
+ });
61
+ // ========================================================================
62
+ // State transitions: closed → open
63
+ // ========================================================================
64
+ it('trips after consecutive auth failures reach threshold', () => {
65
+ const cb = new CircuitBreaker({ failureThreshold: 2 });
66
+ cb.recordAuthFailure(400);
67
+ expect(cb.state).toBe('closed');
68
+ cb.recordAuthFailure(401);
69
+ expect(cb.state).toBe('open');
70
+ });
71
+ it('always counts failures regardless of status code (isAuthError already vetted)', () => {
72
+ const cb = new CircuitBreaker({ failureThreshold: 2 });
73
+ cb.recordAuthFailure(200); // e.g., GraphQL 200 with auth error in body
74
+ expect(cb.getStatus().consecutiveFailures).toBe(1);
75
+ cb.recordAuthFailure(); // no status code at all
76
+ expect(cb.state).toBe('open');
77
+ });
78
+ it('resets failure count on success', () => {
79
+ const cb = new CircuitBreaker({ failureThreshold: 3 });
80
+ cb.recordAuthFailure(401); // 1
81
+ cb.recordAuthFailure(403); // 2
82
+ cb.recordSuccess(); // reset to 0
83
+ cb.recordAuthFailure(400); // 1 — should NOT trip
84
+ expect(cb.state).toBe('closed');
85
+ });
86
+ // ========================================================================
87
+ // State transitions: open → half-open
88
+ // ========================================================================
89
+ it('transitions from open to half-open after resetTimeoutMs', () => {
90
+ const cb = new CircuitBreaker({ failureThreshold: 1, resetTimeoutMs: 5000 });
91
+ cb.recordAuthFailure(401);
92
+ expect(cb.state).toBe('open');
93
+ // Not enough time
94
+ vi.advanceTimersByTime(4999);
95
+ expect(cb.state).toBe('open');
96
+ // Exactly enough time
97
+ vi.advanceTimersByTime(1);
98
+ expect(cb.state).toBe('half-open');
99
+ });
100
+ // ========================================================================
101
+ // State transitions: half-open → closed (probe success)
102
+ // ========================================================================
103
+ it('closes circuit on successful probe in half-open', () => {
104
+ const cb = new CircuitBreaker({ failureThreshold: 1, resetTimeoutMs: 1000 });
105
+ cb.recordAuthFailure(401);
106
+ vi.advanceTimersByTime(1001);
107
+ expect(cb.state).toBe('half-open');
108
+ // Probe succeeds
109
+ cb.canProceed(); // acquire probe
110
+ cb.recordSuccess();
111
+ expect(cb.state).toBe('closed');
112
+ expect(cb.getStatus().consecutiveFailures).toBe(0);
113
+ });
114
+ // ========================================================================
115
+ // State transitions: half-open → open (probe failure + exponential backoff)
116
+ // ========================================================================
117
+ it('reopens circuit on failed probe with exponential backoff', () => {
118
+ const cb = new CircuitBreaker({
119
+ failureThreshold: 1,
120
+ resetTimeoutMs: 1000,
121
+ maxResetTimeoutMs: 16000,
122
+ backoffMultiplier: 2,
123
+ });
124
+ // First trip
125
+ cb.recordAuthFailure(401);
126
+ expect(cb.state).toBe('open');
127
+ expect(cb.getStatus().currentResetTimeoutMs).toBe(1000);
128
+ // Wait for half-open
129
+ vi.advanceTimersByTime(1001);
130
+ expect(cb.state).toBe('half-open');
131
+ // Probe fails → reopen with backoff
132
+ cb.canProceed();
133
+ cb.recordAuthFailure(401);
134
+ expect(cb.state).toBe('open');
135
+ expect(cb.getStatus().currentResetTimeoutMs).toBe(2000); // 1000 * 2
136
+ // Wait 2000ms for next half-open
137
+ vi.advanceTimersByTime(2001);
138
+ expect(cb.state).toBe('half-open');
139
+ // Probe fails again → further backoff
140
+ cb.canProceed();
141
+ cb.recordAuthFailure(401);
142
+ expect(cb.state).toBe('open');
143
+ expect(cb.getStatus().currentResetTimeoutMs).toBe(4000); // 2000 * 2
144
+ });
145
+ it('caps reset timeout at maxResetTimeoutMs', () => {
146
+ const cb = new CircuitBreaker({
147
+ failureThreshold: 1,
148
+ resetTimeoutMs: 1000,
149
+ maxResetTimeoutMs: 3000,
150
+ backoffMultiplier: 2,
151
+ });
152
+ // Trip → open (1000ms)
153
+ cb.recordAuthFailure(401);
154
+ vi.advanceTimersByTime(1001);
155
+ // Probe fail → 2000ms
156
+ cb.canProceed();
157
+ cb.recordAuthFailure(401);
158
+ vi.advanceTimersByTime(2001);
159
+ // Probe fail → 3000ms (capped at max)
160
+ cb.canProceed();
161
+ cb.recordAuthFailure(401);
162
+ expect(cb.getStatus().currentResetTimeoutMs).toBe(3000);
163
+ vi.advanceTimersByTime(3001);
164
+ // Probe fail → still 3000ms (capped)
165
+ cb.canProceed();
166
+ cb.recordAuthFailure(401);
167
+ expect(cb.getStatus().currentResetTimeoutMs).toBe(3000);
168
+ });
169
+ it('resets backoff on successful recovery', () => {
170
+ const cb = new CircuitBreaker({
171
+ failureThreshold: 1,
172
+ resetTimeoutMs: 1000,
173
+ maxResetTimeoutMs: 16000,
174
+ backoffMultiplier: 2,
175
+ });
176
+ // Trip and backoff to 2000ms
177
+ cb.recordAuthFailure(401);
178
+ vi.advanceTimersByTime(1001);
179
+ cb.canProceed();
180
+ cb.recordAuthFailure(401);
181
+ expect(cb.getStatus().currentResetTimeoutMs).toBe(2000);
182
+ // Recover
183
+ vi.advanceTimersByTime(2001);
184
+ cb.canProceed();
185
+ cb.recordSuccess();
186
+ expect(cb.state).toBe('closed');
187
+ expect(cb.getStatus().currentResetTimeoutMs).toBe(1000); // reset to original
188
+ });
189
+ // ========================================================================
190
+ // reset()
191
+ // ========================================================================
192
+ it('reset() returns to closed from any state', () => {
193
+ const cb = new CircuitBreaker({ failureThreshold: 1 });
194
+ cb.recordAuthFailure(401);
195
+ expect(cb.state).toBe('open');
196
+ cb.reset();
197
+ expect(cb.state).toBe('closed');
198
+ expect(cb.getStatus().consecutiveFailures).toBe(0);
199
+ expect(cb.getStatus().currentResetTimeoutMs).toBe(DEFAULT_CIRCUIT_BREAKER_CONFIG.resetTimeoutMs);
200
+ });
201
+ // ========================================================================
202
+ // isAuthError
203
+ // ========================================================================
204
+ it('detects auth errors by HTTP status code', () => {
205
+ const cb = new CircuitBreaker();
206
+ expect(cb.isAuthError({ status: 400 })).toBe(true);
207
+ expect(cb.isAuthError({ status: 401 })).toBe(true);
208
+ expect(cb.isAuthError({ status: 403 })).toBe(true);
209
+ expect(cb.isAuthError({ statusCode: 401 })).toBe(true);
210
+ expect(cb.isAuthError({ response: { status: 403 } })).toBe(true);
211
+ });
212
+ it('does not flag non-auth status codes', () => {
213
+ const cb = new CircuitBreaker();
214
+ expect(cb.isAuthError({ status: 200 })).toBe(false);
215
+ expect(cb.isAuthError({ status: 404 })).toBe(false);
216
+ expect(cb.isAuthError({ status: 500 })).toBe(false);
217
+ expect(cb.isAuthError({ status: 429 })).toBe(false); // rate limit is handled separately
218
+ });
219
+ it('detects GraphQL RATELIMITED error code', () => {
220
+ const cb = new CircuitBreaker();
221
+ // Direct extensions.code
222
+ expect(cb.isAuthError({ extensions: { code: 'RATELIMITED' } })).toBe(true);
223
+ // Nested errors array
224
+ expect(cb.isAuthError({
225
+ errors: [{ extensions: { code: 'RATELIMITED' } }],
226
+ })).toBe(true);
227
+ // In response body
228
+ expect(cb.isAuthError({
229
+ response: {
230
+ body: {
231
+ errors: [{ extensions: { code: 'RATELIMITED' } }],
232
+ },
233
+ },
234
+ })).toBe(true);
235
+ // In response data (alternative shape)
236
+ expect(cb.isAuthError({
237
+ response: {
238
+ data: {
239
+ errors: [{ extensions: { code: 'RATELIMITED' } }],
240
+ },
241
+ },
242
+ })).toBe(true);
243
+ });
244
+ it('detects auth errors by message pattern', () => {
245
+ const cb = new CircuitBreaker();
246
+ expect(cb.isAuthError({ message: 'Access denied - Only app users can create agent activities' })).toBe(true);
247
+ expect(cb.isAuthError({ message: 'Unauthorized request' })).toBe(true);
248
+ expect(cb.isAuthError({ message: 'Forbidden: insufficient permissions' })).toBe(true);
249
+ });
250
+ it('rejects non-error inputs', () => {
251
+ const cb = new CircuitBreaker();
252
+ expect(cb.isAuthError(null)).toBe(false);
253
+ expect(cb.isAuthError(undefined)).toBe(false);
254
+ expect(cb.isAuthError('string')).toBe(false);
255
+ expect(cb.isAuthError(42)).toBe(false);
256
+ });
257
+ it('detects RATELIMITED in error message', () => {
258
+ const cb = new CircuitBreaker();
259
+ expect(cb.isAuthError({ message: 'GraphQL Error: RATELIMITED' })).toBe(true);
260
+ });
261
+ // ========================================================================
262
+ // createOpenError
263
+ // ========================================================================
264
+ it('creates a CircuitOpenError with remaining time info', () => {
265
+ const cb = new CircuitBreaker({ failureThreshold: 1, resetTimeoutMs: 10_000 });
266
+ cb.recordAuthFailure(401);
267
+ // 3 seconds have passed
268
+ vi.advanceTimersByTime(3000);
269
+ const error = cb.createOpenError();
270
+ expect(error).toBeInstanceOf(CircuitOpenError);
271
+ expect(error.code).toBe('CIRCUIT_OPEN');
272
+ expect(error.retryAfterMs).toBeGreaterThan(0);
273
+ expect(error.retryAfterMs).toBeLessThanOrEqual(7000);
274
+ expect(error.message).toMatch(/Circuit breaker is open/);
275
+ });
276
+ // ========================================================================
277
+ // getStatus diagnostic info
278
+ // ========================================================================
279
+ it('provides diagnostic status info', () => {
280
+ const cb = new CircuitBreaker({ failureThreshold: 1, resetTimeoutMs: 5000 });
281
+ let status = cb.getStatus();
282
+ expect(status.state).toBe('closed');
283
+ expect(status.consecutiveFailures).toBe(0);
284
+ expect(status.msSinceOpened).toBeNull();
285
+ cb.recordAuthFailure(401);
286
+ vi.advanceTimersByTime(2000);
287
+ status = cb.getStatus();
288
+ expect(status.state).toBe('open');
289
+ expect(status.consecutiveFailures).toBe(1);
290
+ expect(status.msSinceOpened).toBeGreaterThanOrEqual(2000);
291
+ });
292
+ });
@@ -54,6 +54,14 @@ export declare class LinearStatusTransitionError extends LinearAgentError {
54
54
  readonly toStatus: string;
55
55
  constructor(message: string, issueId: string, fromStatus: string, toStatus: string);
56
56
  }
57
+ /**
58
+ * Error thrown when the circuit breaker is open.
59
+ * All API calls are blocked to prevent wasting rate limit quota.
60
+ */
61
+ export declare class CircuitOpenError extends LinearAgentError {
62
+ readonly retryAfterMs: number;
63
+ constructor(message: string, retryAfterMs: number);
64
+ }
57
65
  /**
58
66
  * Error thrown when agent spawning fails
59
67
  */
@@ -72,6 +80,10 @@ export declare function isLinearAgentError(error: unknown): error is LinearAgent
72
80
  * Type guard to check if an error is an AgentSpawnError
73
81
  */
74
82
  export declare function isAgentSpawnError(error: unknown): error is AgentSpawnError;
83
+ /**
84
+ * Type guard to check if an error is a CircuitOpenError
85
+ */
86
+ export declare function isCircuitOpenError(error: unknown): error is CircuitOpenError;
75
87
  /**
76
88
  * Type guard to check if an error is retryable
77
89
  */
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/errors.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,qBAAa,gBAAiB,SAAQ,KAAK;aAGvB,IAAI,EAAE,MAAM;aACZ,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;gBAFjD,OAAO,EAAE,MAAM,EACC,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,YAAA;CAQpD;AAED;;GAEG;AACH,qBAAa,cAAe,SAAQ,gBAAgB;aAGhC,UAAU,EAAE,MAAM;aAClB,QAAQ,CAAC,EAAE,OAAO;gBAFlC,OAAO,EAAE,MAAM,EACC,UAAU,EAAE,MAAM,EAClB,QAAQ,CAAC,EAAE,OAAO,YAAA;CAKrC;AAED;;GAEG;AACH,qBAAa,yBAA0B,SAAQ,gBAAgB;aAG3C,QAAQ,EAAE,MAAM;aAChB,SAAS,EAAE,KAAK;gBAFhC,OAAO,EAAE,MAAM,EACC,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,KAAK;CAQnC;AAED;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,gBAAgB;aAGpC,SAAS,CAAC,EAAE,MAAM;aAClB,OAAO,CAAC,EAAE,MAAM;gBAFhC,OAAO,EAAE,MAAM,EACC,SAAS,CAAC,EAAE,MAAM,YAAA,EAClB,OAAO,CAAC,EAAE,MAAM,YAAA;CAKnC;AAED;;GAEG;AACH,qBAAa,mBAAoB,SAAQ,gBAAgB;aAGrC,YAAY,EAAE,MAAM;aACpB,SAAS,CAAC,EAAE,MAAM;gBAFlC,OAAO,EAAE,MAAM,EACC,YAAY,EAAE,MAAM,EACpB,SAAS,CAAC,EAAE,MAAM,YAAA;CAKrC;AAED;;GAEG;AACH,qBAAa,eAAgB,SAAQ,gBAAgB;aACN,SAAS,CAAC,EAAE,MAAM;gBAAnD,OAAO,EAAE,MAAM,EAAkB,SAAS,CAAC,EAAE,MAAM,YAAA;CAIhE;AAED;;GAEG;AACH,qBAAa,2BAA4B,SAAQ,gBAAgB;aAG7C,OAAO,EAAE,MAAM;aACf,UAAU,EAAE,MAAM;aAClB,QAAQ,EAAE,MAAM;gBAHhC,OAAO,EAAE,MAAM,EACC,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM;CASnC;AAED;;GAEG;AACH,qBAAa,eAAgB,SAAQ,gBAAgB;aAGjC,OAAO,EAAE,MAAM;aACf,SAAS,CAAC,EAAE,MAAM;aAClB,WAAW,EAAE,OAAO;aACpB,KAAK,CAAC,EAAE,KAAK;gBAJ7B,OAAO,EAAE,MAAM,EACC,OAAO,EAAE,MAAM,EACf,SAAS,CAAC,EAAE,MAAM,YAAA,EAClB,WAAW,GAAE,OAAe,EAC5B,KAAK,CAAC,EAAE,KAAK,YAAA;CAUhC;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,gBAAgB,CAE5E;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,eAAe,CAE1E;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,OAAO,EACd,oBAAoB,GAAE,MAAM,EAA8B,GACzD,OAAO,CAkBT"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/errors.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,qBAAa,gBAAiB,SAAQ,KAAK;aAGvB,IAAI,EAAE,MAAM;aACZ,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;gBAFjD,OAAO,EAAE,MAAM,EACC,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,YAAA;CAQpD;AAED;;GAEG;AACH,qBAAa,cAAe,SAAQ,gBAAgB;aAGhC,UAAU,EAAE,MAAM;aAClB,QAAQ,CAAC,EAAE,OAAO;gBAFlC,OAAO,EAAE,MAAM,EACC,UAAU,EAAE,MAAM,EAClB,QAAQ,CAAC,EAAE,OAAO,YAAA;CAKrC;AAED;;GAEG;AACH,qBAAa,yBAA0B,SAAQ,gBAAgB;aAG3C,QAAQ,EAAE,MAAM;aAChB,SAAS,EAAE,KAAK;gBAFhC,OAAO,EAAE,MAAM,EACC,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,KAAK;CAQnC;AAED;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,gBAAgB;aAGpC,SAAS,CAAC,EAAE,MAAM;aAClB,OAAO,CAAC,EAAE,MAAM;gBAFhC,OAAO,EAAE,MAAM,EACC,SAAS,CAAC,EAAE,MAAM,YAAA,EAClB,OAAO,CAAC,EAAE,MAAM,YAAA;CAKnC;AAED;;GAEG;AACH,qBAAa,mBAAoB,SAAQ,gBAAgB;aAGrC,YAAY,EAAE,MAAM;aACpB,SAAS,CAAC,EAAE,MAAM;gBAFlC,OAAO,EAAE,MAAM,EACC,YAAY,EAAE,MAAM,EACpB,SAAS,CAAC,EAAE,MAAM,YAAA;CAKrC;AAED;;GAEG;AACH,qBAAa,eAAgB,SAAQ,gBAAgB;aACN,SAAS,CAAC,EAAE,MAAM;gBAAnD,OAAO,EAAE,MAAM,EAAkB,SAAS,CAAC,EAAE,MAAM,YAAA;CAIhE;AAED;;GAEG;AACH,qBAAa,2BAA4B,SAAQ,gBAAgB;aAG7C,OAAO,EAAE,MAAM;aACf,UAAU,EAAE,MAAM;aAClB,QAAQ,EAAE,MAAM;gBAHhC,OAAO,EAAE,MAAM,EACC,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM;CASnC;AAED;;;GAGG;AACH,qBAAa,gBAAiB,SAAQ,gBAAgB;aAGlC,YAAY,EAAE,MAAM;gBADpC,OAAO,EAAE,MAAM,EACC,YAAY,EAAE,MAAM;CAKvC;AAED;;GAEG;AACH,qBAAa,eAAgB,SAAQ,gBAAgB;aAGjC,OAAO,EAAE,MAAM;aACf,SAAS,CAAC,EAAE,MAAM;aAClB,WAAW,EAAE,OAAO;aACpB,KAAK,CAAC,EAAE,KAAK;gBAJ7B,OAAO,EAAE,MAAM,EACC,OAAO,EAAE,MAAM,EACf,SAAS,CAAC,EAAE,MAAM,YAAA,EAClB,WAAW,GAAE,OAAe,EAC5B,KAAK,CAAC,EAAE,KAAK,YAAA;CAUhC;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,gBAAgB,CAE5E;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,eAAe,CAE1E;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,gBAAgB,CAE5E;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,OAAO,EACd,oBAAoB,GAAE,MAAM,EAA8B,GACzD,OAAO,CAkBT"}
@@ -99,6 +99,18 @@ export class LinearStatusTransitionError extends LinearAgentError {
99
99
  this.name = 'LinearStatusTransitionError';
100
100
  }
101
101
  }
102
+ /**
103
+ * Error thrown when the circuit breaker is open.
104
+ * All API calls are blocked to prevent wasting rate limit quota.
105
+ */
106
+ export class CircuitOpenError extends LinearAgentError {
107
+ retryAfterMs;
108
+ constructor(message, retryAfterMs) {
109
+ super(message, 'CIRCUIT_OPEN', { retryAfterMs });
110
+ this.retryAfterMs = retryAfterMs;
111
+ this.name = 'CircuitOpenError';
112
+ }
113
+ }
102
114
  /**
103
115
  * Error thrown when agent spawning fails
104
116
  */
@@ -133,6 +145,12 @@ export function isLinearAgentError(error) {
133
145
  export function isAgentSpawnError(error) {
134
146
  return error instanceof AgentSpawnError;
135
147
  }
148
+ /**
149
+ * Type guard to check if an error is a CircuitOpenError
150
+ */
151
+ export function isCircuitOpenError(error) {
152
+ return error instanceof CircuitOpenError;
153
+ }
136
154
  /**
137
155
  * Type guard to check if an error is retryable
138
156
  */