@supaku/agentfactory-linear 0.7.18 → 0.7.19

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.
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
2
  import { LinearAgentClient } from './agent-client.js';
3
3
  import { TokenBucket } from './rate-limiter.js';
4
+ import { CircuitBreaker } from './circuit-breaker.js';
4
5
  // ---------------------------------------------------------------------------
5
6
  // Mock helpers
6
7
  // ---------------------------------------------------------------------------
@@ -30,6 +31,7 @@ function createClientWithProject(project) {
30
31
  writable: false,
31
32
  });
32
33
  Object.defineProperty(client, 'rateLimiter', { value: new TokenBucket(), writable: false });
34
+ Object.defineProperty(client, 'circuitBreaker', { value: new CircuitBreaker(), writable: false });
33
35
  Object.defineProperty(client, 'statusCache', { value: new Map(), writable: false });
34
36
  return client;
35
37
  }
@@ -9,6 +9,7 @@ export declare class LinearAgentClient {
9
9
  private readonly client;
10
10
  private readonly retryConfig;
11
11
  private readonly rateLimiter;
12
+ private readonly circuitBreaker;
12
13
  private statusCache;
13
14
  constructor(config: LinearAgentClientConfig);
14
15
  /**
@@ -16,11 +17,15 @@ export declare class LinearAgentClient {
16
17
  */
17
18
  get linearClient(): LinearClient;
18
19
  /**
19
- * Execute an operation with retry logic and rate limiting.
20
+ * Execute an operation with circuit breaker, rate limiting, and retry logic.
20
21
  *
21
- * On HTTP 429 (rate limited):
22
- * - Uses the Retry-After header value for the wait delay (falls back to 60s)
23
- * - Penalizes the token bucket so concurrent callers also back off
22
+ * Order of operations:
23
+ * 1. Check circuit breaker if open, throw CircuitOpenError (zero quota consumed)
24
+ * 2. Acquire rate limit token
25
+ * 3. Execute the operation
26
+ * 4. On success: record success on circuit breaker
27
+ * 5. On auth error: record failure on circuit breaker (may trip it)
28
+ * 6. On retryable error: retry with exponential backoff
24
29
  */
25
30
  private withRetry;
26
31
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"agent-client.d.ts","sourceRoot":"","sources":["../../src/agent-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"agent-client.d.ts","sourceRoot":"","sources":["../../src/agent-client.ts"],"names":[],"mappings":"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"}
@@ -2,6 +2,7 @@ import { LinearClient, AgentActivitySignal as LinearAgentActivitySignal, IssueRe
2
2
  import { LinearApiError, LinearStatusTransitionError } from './errors.js';
3
3
  import { withRetry, DEFAULT_RETRY_CONFIG } from './retry.js';
4
4
  import { TokenBucket, extractRetryAfterMs } from './rate-limiter.js';
5
+ import { CircuitBreaker } from './circuit-breaker.js';
5
6
  /**
6
7
  * Core Linear Agent Client
7
8
  * Wraps @linear/sdk with retry logic and helper methods
@@ -10,6 +11,7 @@ export class LinearAgentClient {
10
11
  client;
11
12
  retryConfig;
12
13
  rateLimiter;
14
+ circuitBreaker;
13
15
  statusCache = new Map();
14
16
  constructor(config) {
15
17
  this.client = new LinearClient({
@@ -20,7 +22,8 @@ export class LinearAgentClient {
20
22
  ...DEFAULT_RETRY_CONFIG,
21
23
  ...config.retry,
22
24
  };
23
- this.rateLimiter = new TokenBucket(config.rateLimit);
25
+ this.rateLimiter = config.rateLimiterStrategy ?? new TokenBucket(config.rateLimit);
26
+ this.circuitBreaker = config.circuitBreakerStrategy ?? new CircuitBreaker(config.circuitBreaker);
24
27
  }
25
28
  /**
26
29
  * Get the underlying LinearClient instance
@@ -29,16 +32,44 @@ export class LinearAgentClient {
29
32
  return this.client;
30
33
  }
31
34
  /**
32
- * Execute an operation with retry logic and rate limiting.
35
+ * Execute an operation with circuit breaker, rate limiting, and retry logic.
33
36
  *
34
- * On HTTP 429 (rate limited):
35
- * - Uses the Retry-After header value for the wait delay (falls back to 60s)
36
- * - Penalizes the token bucket so concurrent callers also back off
37
+ * Order of operations:
38
+ * 1. Check circuit breaker if open, throw CircuitOpenError (zero quota consumed)
39
+ * 2. Acquire rate limit token
40
+ * 3. Execute the operation
41
+ * 4. On success: record success on circuit breaker
42
+ * 5. On auth error: record failure on circuit breaker (may trip it)
43
+ * 6. On retryable error: retry with exponential backoff
37
44
  */
38
45
  async withRetry(fn) {
39
46
  return withRetry(async () => {
47
+ // Check circuit breaker BEFORE acquiring a rate limit token
48
+ const canProceed = await this.circuitBreaker.canProceed();
49
+ if (!canProceed) {
50
+ // Create a descriptive error; if the breaker is a CircuitBreaker instance, use its helper
51
+ const breaker = this.circuitBreaker;
52
+ if (typeof breaker.createOpenError === 'function') {
53
+ throw breaker.createOpenError();
54
+ }
55
+ throw new LinearApiError('Circuit breaker is open — API calls blocked', 503);
56
+ }
40
57
  await this.rateLimiter.acquire();
41
- return fn();
58
+ try {
59
+ const result = await fn();
60
+ // Record success to close/reset the circuit
61
+ await this.circuitBreaker.recordSuccess();
62
+ return result;
63
+ }
64
+ catch (error) {
65
+ // Check if this is an auth error that should trip the circuit
66
+ if (this.circuitBreaker.isAuthError(error)) {
67
+ const statusCode = extractAuthStatusCode(error);
68
+ await this.circuitBreaker.recordAuthFailure(statusCode);
69
+ console.warn(`[LinearAgentClient] Auth error detected (status ${statusCode}), circuit breaker notified`);
70
+ }
71
+ throw error;
72
+ }
42
73
  }, {
43
74
  config: this.retryConfig,
44
75
  getRetryAfterMs: extractRetryAfterMs,
@@ -448,6 +479,15 @@ export class LinearAgentClient {
448
479
  * @returns Array of issue data with childCount for parent detection
449
480
  */
450
481
  async listProjectIssues(project) {
482
+ // Check circuit breaker before consuming rate limit token
483
+ const canProceed = await this.circuitBreaker.canProceed();
484
+ if (!canProceed) {
485
+ const breaker = this.circuitBreaker;
486
+ if (typeof breaker.createOpenError === 'function') {
487
+ throw breaker.createOpenError();
488
+ }
489
+ throw new LinearApiError('Circuit breaker is open — API calls blocked', 503);
490
+ }
451
491
  await this.rateLimiter.acquire();
452
492
  const query = `
453
493
  query ListProjectIssues($filter: IssueFilter!) {
@@ -468,25 +508,36 @@ export class LinearAgentClient {
468
508
  }
469
509
  `;
470
510
  const terminalStatuses = ['Accepted', 'Canceled', 'Duplicate'];
471
- const result = await this.client.client.rawRequest(query, {
472
- filter: {
473
- project: { name: { eq: project } },
474
- state: { name: { nin: terminalStatuses } },
475
- },
476
- });
477
- const data = result.data;
478
- return data.issues.nodes.map((node) => ({
479
- id: node.id,
480
- identifier: node.identifier,
481
- title: node.title,
482
- description: node.description ?? undefined,
483
- status: node.state?.name ?? 'Backlog',
484
- labels: node.labels.nodes.map((l) => l.name),
485
- createdAt: new Date(node.createdAt).getTime(),
486
- parentId: node.parent?.id ?? undefined,
487
- project: node.project?.name ?? undefined,
488
- childCount: node.children.nodes.length,
489
- }));
511
+ try {
512
+ const result = await this.client.client.rawRequest(query, {
513
+ filter: {
514
+ project: { name: { eq: project } },
515
+ state: { name: { nin: terminalStatuses } },
516
+ },
517
+ });
518
+ // Record success on circuit breaker
519
+ await this.circuitBreaker.recordSuccess();
520
+ const data = result.data;
521
+ return data.issues.nodes.map((node) => ({
522
+ id: node.id,
523
+ identifier: node.identifier,
524
+ title: node.title,
525
+ description: node.description ?? undefined,
526
+ status: node.state?.name ?? 'Backlog',
527
+ labels: node.labels.nodes.map((l) => l.name),
528
+ createdAt: new Date(node.createdAt).getTime(),
529
+ parentId: node.parent?.id ?? undefined,
530
+ project: node.project?.name ?? undefined,
531
+ childCount: node.children.nodes.length,
532
+ }));
533
+ }
534
+ catch (error) {
535
+ if (this.circuitBreaker.isAuthError(error)) {
536
+ const statusCode = extractAuthStatusCode(error);
537
+ await this.circuitBreaker.recordAuthFailure(statusCode);
538
+ }
539
+ throw error;
540
+ }
490
541
  }
491
542
  /**
492
543
  * Check if an issue has child issues (is a parent issue)
@@ -642,3 +693,27 @@ export class LinearAgentClient {
642
693
  export function createLinearAgentClient(config) {
643
694
  return new LinearAgentClient(config);
644
695
  }
696
+ // ---------------------------------------------------------------------------
697
+ // Helpers
698
+ // ---------------------------------------------------------------------------
699
+ /**
700
+ * Extract HTTP status code from an error for circuit breaker recording.
701
+ */
702
+ function extractAuthStatusCode(error) {
703
+ if (typeof error !== 'object' || error === null)
704
+ return 0;
705
+ const err = error;
706
+ if (typeof err.status === 'number')
707
+ return err.status;
708
+ if (typeof err.statusCode === 'number')
709
+ return err.statusCode;
710
+ const response = err.response;
711
+ if (response) {
712
+ if (typeof response.status === 'number')
713
+ return response.status;
714
+ if (typeof response.statusCode === 'number')
715
+ return response.statusCode;
716
+ }
717
+ // Default to 400 for auth errors detected by message pattern
718
+ return 400;
719
+ }
@@ -0,0 +1,75 @@
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
+ */
41
+ recordAuthFailure(statusCode: number): void;
42
+ /**
43
+ * Check if an error is an auth/rate-limit error that should count as a circuit failure.
44
+ *
45
+ * Detects:
46
+ * - HTTP status codes in authErrorCodes (400, 401, 403)
47
+ * - Linear GraphQL RATELIMITED error code in response body
48
+ * - Linear SDK error objects with nested error details
49
+ */
50
+ isAuthError(error: unknown): boolean;
51
+ /**
52
+ * Extract the status code from an auth error, or 0 if not determinable.
53
+ */
54
+ extractStatusCode(error: unknown): number;
55
+ /**
56
+ * Reset the circuit breaker to closed state.
57
+ */
58
+ reset(): void;
59
+ /**
60
+ * Get diagnostic info for logging/monitoring.
61
+ */
62
+ getStatus(): {
63
+ state: CircuitState;
64
+ consecutiveFailures: number;
65
+ currentResetTimeoutMs: number;
66
+ msSinceOpened: number | null;
67
+ };
68
+ /**
69
+ * Create a CircuitOpenError with current diagnostic info.
70
+ */
71
+ createOpenError(): CircuitOpenError;
72
+ private trip;
73
+ private shouldTransitionToHalfOpen;
74
+ }
75
+ //# 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;;OAEG;IACH,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAwB3C;;;;;;;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,231 @@
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
+ */
76
+ recordAuthFailure(statusCode) {
77
+ this.probeInFlight = false;
78
+ if (!this.config.authErrorCodes.includes(statusCode)) {
79
+ return;
80
+ }
81
+ this.consecutiveFailures++;
82
+ if (this._state === 'half-open') {
83
+ // Probe failed — reopen with exponential backoff
84
+ this.trip();
85
+ this.currentResetTimeoutMs = Math.min(this.currentResetTimeoutMs * this.config.backoffMultiplier, this.config.maxResetTimeoutMs);
86
+ return;
87
+ }
88
+ if (this.consecutiveFailures >= this.config.failureThreshold) {
89
+ this.trip();
90
+ }
91
+ }
92
+ /**
93
+ * Check if an error is an auth/rate-limit error that should count as a circuit failure.
94
+ *
95
+ * Detects:
96
+ * - HTTP status codes in authErrorCodes (400, 401, 403)
97
+ * - Linear GraphQL RATELIMITED error code in response body
98
+ * - Linear SDK error objects with nested error details
99
+ */
100
+ isAuthError(error) {
101
+ if (typeof error !== 'object' || error === null)
102
+ return false;
103
+ const err = error;
104
+ // Check HTTP status code
105
+ const statusCode = extractStatusCode(err);
106
+ if (statusCode !== null && this.config.authErrorCodes.includes(statusCode)) {
107
+ return true;
108
+ }
109
+ // Check for Linear GraphQL RATELIMITED error
110
+ if (isGraphQLRateLimited(err)) {
111
+ return true;
112
+ }
113
+ // Check error message for known auth failure patterns
114
+ const message = err.message ?? '';
115
+ if (/access denied|unauthorized|forbidden/i.test(message)) {
116
+ return true;
117
+ }
118
+ return false;
119
+ }
120
+ /**
121
+ * Extract the status code from an auth error, or 0 if not determinable.
122
+ */
123
+ extractStatusCode(error) {
124
+ if (typeof error !== 'object' || error === null)
125
+ return 0;
126
+ return extractStatusCode(error) ?? 0;
127
+ }
128
+ /**
129
+ * Reset the circuit breaker to closed state.
130
+ */
131
+ reset() {
132
+ this._state = 'closed';
133
+ this.consecutiveFailures = 0;
134
+ this.probeInFlight = false;
135
+ this.currentResetTimeoutMs = this.config.resetTimeoutMs;
136
+ }
137
+ /**
138
+ * Get diagnostic info for logging/monitoring.
139
+ */
140
+ getStatus() {
141
+ return {
142
+ state: this.state,
143
+ consecutiveFailures: this.consecutiveFailures,
144
+ currentResetTimeoutMs: this.currentResetTimeoutMs,
145
+ msSinceOpened: this.openedAt > 0 ? Date.now() - this.openedAt : null,
146
+ };
147
+ }
148
+ /**
149
+ * Create a CircuitOpenError with current diagnostic info.
150
+ */
151
+ createOpenError() {
152
+ const timeRemaining = Math.max(0, this.currentResetTimeoutMs - (Date.now() - this.openedAt));
153
+ return new CircuitOpenError(`Circuit breaker is open — Linear API calls blocked for ${Math.ceil(timeRemaining / 1000)}s. ` +
154
+ `${this.consecutiveFailures} consecutive auth failures detected.`, timeRemaining);
155
+ }
156
+ // ---------------------------------------------------------------------------
157
+ // Private
158
+ // ---------------------------------------------------------------------------
159
+ trip() {
160
+ this._state = 'open';
161
+ this.openedAt = Date.now();
162
+ }
163
+ shouldTransitionToHalfOpen() {
164
+ return Date.now() - this.openedAt >= this.currentResetTimeoutMs;
165
+ }
166
+ }
167
+ // ---------------------------------------------------------------------------
168
+ // Helpers
169
+ // ---------------------------------------------------------------------------
170
+ /**
171
+ * Extract HTTP status code from various error shapes
172
+ */
173
+ function extractStatusCode(err) {
174
+ // Direct status/statusCode property
175
+ if (typeof err.status === 'number')
176
+ return err.status;
177
+ if (typeof err.statusCode === 'number')
178
+ return err.statusCode;
179
+ // Nested in response
180
+ const response = err.response;
181
+ if (response) {
182
+ if (typeof response.status === 'number')
183
+ return response.status;
184
+ if (typeof response.statusCode === 'number')
185
+ return response.statusCode;
186
+ }
187
+ return null;
188
+ }
189
+ /**
190
+ * Check if the error contains a Linear GraphQL RATELIMITED error code.
191
+ *
192
+ * Linear returns HTTP 200 with a GraphQL error body when rate-limited:
193
+ * { errors: [{ extensions: { code: 'RATELIMITED' } }] }
194
+ */
195
+ function isGraphQLRateLimited(err) {
196
+ // Check error.extensions.code directly (Linear SDK error shape)
197
+ const extensions = err.extensions;
198
+ if (extensions?.code === 'RATELIMITED')
199
+ return true;
200
+ // Check nested errors array (raw GraphQL response shape)
201
+ const errors = err.errors;
202
+ if (Array.isArray(errors)) {
203
+ for (const gqlError of errors) {
204
+ const ext = gqlError.extensions;
205
+ if (ext?.code === 'RATELIMITED')
206
+ return true;
207
+ }
208
+ }
209
+ // Check response body for GraphQL errors
210
+ const response = err.response;
211
+ if (response) {
212
+ const body = response.body;
213
+ const data = response.data;
214
+ const target = body ?? data;
215
+ if (target) {
216
+ const bodyErrors = target.errors;
217
+ if (Array.isArray(bodyErrors)) {
218
+ for (const gqlError of bodyErrors) {
219
+ const ext = gqlError.extensions;
220
+ if (ext?.code === 'RATELIMITED')
221
+ return true;
222
+ }
223
+ }
224
+ }
225
+ }
226
+ // Check error message as last resort
227
+ const message = err.message ?? '';
228
+ if (message.includes('RATELIMITED'))
229
+ return true;
230
+ return false;
231
+ }
@@ -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":""}