@supaku/agentfactory-linear 0.7.17 → 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.
- package/dist/src/agent-client-project-repo.test.js +2 -0
- package/dist/src/agent-client.d.ts +9 -4
- package/dist/src/agent-client.d.ts.map +1 -1
- package/dist/src/agent-client.js +100 -25
- package/dist/src/circuit-breaker.d.ts +75 -0
- package/dist/src/circuit-breaker.d.ts.map +1 -0
- package/dist/src/circuit-breaker.js +231 -0
- package/dist/src/circuit-breaker.test.d.ts +2 -0
- package/dist/src/circuit-breaker.test.d.ts.map +1 -0
- package/dist/src/circuit-breaker.test.js +292 -0
- package/dist/src/errors.d.ts +12 -0
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +18 -0
- package/dist/src/index.d.ts +7 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +5 -1
- package/dist/src/issue-tracker-proxy.d.ts +140 -0
- package/dist/src/issue-tracker-proxy.d.ts.map +1 -0
- package/dist/src/issue-tracker-proxy.js +10 -0
- package/dist/src/proxy-client.d.ts +103 -0
- package/dist/src/proxy-client.d.ts.map +1 -0
- package/dist/src/proxy-client.js +191 -0
- package/dist/src/types.d.ts +50 -0
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
20
|
+
* Execute an operation with circuit breaker, rate limiting, and retry logic.
|
|
20
21
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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":"AAAA,OAAO,EACL,YAAY,EAGb,MAAM,aAAa,CAAA;AACpB,OAAO,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAA;AACjD,OAAO,KAAK,EACV,uBAAuB,EACvB,oBAAoB,EACpB,aAAa,EAEb,wBAAwB,EACxB,mBAAmB,EACnB,uBAAuB,EACvB,wBAAwB,EACxB,8BAA8B,EAC9B,wBAAwB,EACxB,wBAAwB,EACxB,mBAAmB,EACnB,wBAAwB,EAExB,oBAAoB,EACpB,iBAAiB,EAEjB,aAAa,EACb,cAAc,
|
|
1
|
+
{"version":3,"file":"agent-client.d.ts","sourceRoot":"","sources":["../../src/agent-client.ts"],"names":[],"mappings":"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"}
|
package/dist/src/agent-client.js
CHANGED
|
@@ -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
|
|
35
|
+
* Execute an operation with circuit breaker, rate limiting, and retry logic.
|
|
33
36
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"circuit-breaker.test.d.ts","sourceRoot":"","sources":["../../src/circuit-breaker.test.ts"],"names":[],"mappings":""}
|