@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.
@@ -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
  /**
@@ -136,6 +141,8 @@ export declare class LinearAgentClient {
136
141
  /**
137
142
  * Get all relations for an issue (both outgoing and incoming)
138
143
  *
144
+ * Uses a single raw GraphQL query instead of N+1 lazy-loaded SDK calls.
145
+ *
139
146
  * @param issueId - The issue ID or identifier (e.g., "SUP-123")
140
147
  * @returns Relations result with both directions of relationships
141
148
  */
@@ -194,6 +201,7 @@ export declare class LinearAgentClient {
194
201
  /**
195
202
  * Get lightweight sub-issue statuses (no blocking relations)
196
203
  *
204
+ * Uses a single raw GraphQL query instead of N+1 lazy-loaded SDK calls.
197
205
  * Returns identifier, title, and status for each sub-issue.
198
206
  * Used by QA and acceptance agents to validate sub-issue completion
199
207
  * without the overhead of fetching the full dependency graph.
@@ -216,6 +224,10 @@ export declare class LinearAgentClient {
216
224
  /**
217
225
  * Get sub-issues with their blocking relations for dependency graph building
218
226
  *
227
+ * Uses a single raw GraphQL query instead of N+1 lazy-loaded SDK calls.
228
+ * Previous implementation made 2 + 4N + M API calls (where N = children,
229
+ * M = total relations). This version makes exactly 1 API call.
230
+ *
219
231
  * Builds a complete dependency graph of a parent issue's children, including
220
232
  * which sub-issues block which other sub-issues. This is used by the coordinator
221
233
  * agent to determine execution order.
@@ -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,45 @@ 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
+ const msg = error instanceof Error ? error.message : String(error);
70
+ console.warn(`[LinearAgentClient] Auth error detected (status ${statusCode}), circuit breaker notified: ${msg}`);
71
+ }
72
+ throw error;
73
+ }
42
74
  }, {
43
75
  config: this.retryConfig,
44
76
  getRetryAfterMs: extractRetryAfterMs,
@@ -346,47 +378,79 @@ export class LinearAgentClient {
346
378
  /**
347
379
  * Get all relations for an issue (both outgoing and incoming)
348
380
  *
381
+ * Uses a single raw GraphQL query instead of N+1 lazy-loaded SDK calls.
382
+ *
349
383
  * @param issueId - The issue ID or identifier (e.g., "SUP-123")
350
384
  * @returns Relations result with both directions of relationships
351
385
  */
352
386
  async getIssueRelations(issueId) {
353
- return this.withRetry(async () => {
354
- const issue = await this.client.issue(issueId);
355
- if (!issue) {
356
- throw new LinearApiError(`Issue not found: ${issueId}`, 404);
387
+ const canProceed = await this.circuitBreaker.canProceed();
388
+ if (!canProceed) {
389
+ const breaker = this.circuitBreaker;
390
+ if (typeof breaker.createOpenError === 'function') {
391
+ throw breaker.createOpenError();
357
392
  }
358
- // Get outgoing relations (this issue -> other issues)
359
- const relationsConnection = await issue.relations();
360
- const relations = [];
361
- for (const relation of relationsConnection.nodes) {
362
- const relatedIssue = await relation.relatedIssue;
363
- relations.push({
364
- id: relation.id,
365
- type: relation.type,
366
- issueId: issue.id,
367
- issueIdentifier: issue.identifier,
368
- relatedIssueId: relatedIssue?.id ?? '',
369
- relatedIssueIdentifier: relatedIssue?.identifier,
370
- createdAt: relation.createdAt,
371
- });
393
+ throw new LinearApiError('Circuit breaker is open API calls blocked', 503);
394
+ }
395
+ await this.rateLimiter.acquire();
396
+ const query = `
397
+ query IssueRelations($id: String!) {
398
+ issue(id: $id) {
399
+ id
400
+ identifier
401
+ relations(first: 50) {
402
+ nodes {
403
+ id
404
+ type
405
+ createdAt
406
+ relatedIssue { id identifier }
372
407
  }
373
- // Get incoming relations (other issues -> this issue)
374
- const inverseRelationsConnection = await issue.inverseRelations();
375
- const inverseRelations = [];
376
- for (const relation of inverseRelationsConnection.nodes) {
377
- const sourceIssue = await relation.issue;
378
- inverseRelations.push({
379
- id: relation.id,
380
- type: relation.type,
381
- issueId: sourceIssue?.id ?? '',
382
- issueIdentifier: sourceIssue?.identifier,
383
- relatedIssueId: issue.id,
384
- relatedIssueIdentifier: issue.identifier,
385
- createdAt: relation.createdAt,
386
- });
408
+ }
409
+ inverseRelations(first: 50) {
410
+ nodes {
411
+ id
412
+ type
413
+ createdAt
414
+ issue { id identifier }
415
+ }
416
+ }
417
+ }
418
+ }
419
+ `;
420
+ try {
421
+ const result = await this.client.client.rawRequest(query, { id: issueId });
422
+ await this.circuitBreaker.recordSuccess();
423
+ const data = result.data;
424
+ if (!data.issue) {
425
+ throw new LinearApiError(`Issue not found: ${issueId}`, 404);
387
426
  }
427
+ const relations = data.issue.relations.nodes.map((rel) => ({
428
+ id: rel.id,
429
+ type: rel.type,
430
+ issueId: data.issue.id,
431
+ issueIdentifier: data.issue.identifier,
432
+ relatedIssueId: rel.relatedIssue?.id ?? '',
433
+ relatedIssueIdentifier: rel.relatedIssue?.identifier,
434
+ createdAt: new Date(rel.createdAt),
435
+ }));
436
+ const inverseRelations = data.issue.inverseRelations.nodes.map((rel) => ({
437
+ id: rel.id,
438
+ type: rel.type,
439
+ issueId: rel.issue?.id ?? '',
440
+ issueIdentifier: rel.issue?.identifier,
441
+ relatedIssueId: data.issue.id,
442
+ relatedIssueIdentifier: data.issue.identifier,
443
+ createdAt: new Date(rel.createdAt),
444
+ }));
388
445
  return { relations, inverseRelations };
389
- });
446
+ }
447
+ catch (error) {
448
+ if (this.circuitBreaker.isAuthError(error)) {
449
+ const statusCode = extractAuthStatusCode(error);
450
+ await this.circuitBreaker.recordAuthFailure(statusCode);
451
+ }
452
+ throw error;
453
+ }
390
454
  }
391
455
  /**
392
456
  * Delete an issue relation
@@ -448,6 +512,15 @@ export class LinearAgentClient {
448
512
  * @returns Array of issue data with childCount for parent detection
449
513
  */
450
514
  async listProjectIssues(project) {
515
+ // Check circuit breaker before consuming rate limit token
516
+ const canProceed = await this.circuitBreaker.canProceed();
517
+ if (!canProceed) {
518
+ const breaker = this.circuitBreaker;
519
+ if (typeof breaker.createOpenError === 'function') {
520
+ throw breaker.createOpenError();
521
+ }
522
+ throw new LinearApiError('Circuit breaker is open — API calls blocked', 503);
523
+ }
451
524
  await this.rateLimiter.acquire();
452
525
  const query = `
453
526
  query ListProjectIssues($filter: IssueFilter!) {
@@ -468,25 +541,36 @@ export class LinearAgentClient {
468
541
  }
469
542
  `;
470
543
  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
- }));
544
+ try {
545
+ const result = await this.client.client.rawRequest(query, {
546
+ filter: {
547
+ project: { name: { eq: project } },
548
+ state: { name: { nin: terminalStatuses } },
549
+ },
550
+ });
551
+ // Record success on circuit breaker
552
+ await this.circuitBreaker.recordSuccess();
553
+ const data = result.data;
554
+ return data.issues.nodes.map((node) => ({
555
+ id: node.id,
556
+ identifier: node.identifier,
557
+ title: node.title,
558
+ description: node.description ?? undefined,
559
+ status: node.state?.name ?? 'Backlog',
560
+ labels: node.labels.nodes.map((l) => l.name),
561
+ createdAt: new Date(node.createdAt).getTime(),
562
+ parentId: node.parent?.id ?? undefined,
563
+ project: node.project?.name ?? undefined,
564
+ childCount: node.children.nodes.length,
565
+ }));
566
+ }
567
+ catch (error) {
568
+ if (this.circuitBreaker.isAuthError(error)) {
569
+ const statusCode = extractAuthStatusCode(error);
570
+ await this.circuitBreaker.recordAuthFailure(statusCode);
571
+ }
572
+ throw error;
573
+ }
490
574
  }
491
575
  /**
492
576
  * Check if an issue has child issues (is a parent issue)
@@ -507,6 +591,7 @@ export class LinearAgentClient {
507
591
  /**
508
592
  * Get lightweight sub-issue statuses (no blocking relations)
509
593
  *
594
+ * Uses a single raw GraphQL query instead of N+1 lazy-loaded SDK calls.
510
595
  * Returns identifier, title, and status for each sub-issue.
511
596
  * Used by QA and acceptance agents to validate sub-issue completion
512
597
  * without the overhead of fetching the full dependency graph.
@@ -515,23 +600,48 @@ export class LinearAgentClient {
515
600
  * @returns Array of sub-issue statuses
516
601
  */
517
602
  async getSubIssueStatuses(issueIdOrIdentifier) {
518
- return this.withRetry(async () => {
519
- const parentIssue = await this.client.issue(issueIdOrIdentifier);
520
- if (!parentIssue) {
603
+ const canProceed = await this.circuitBreaker.canProceed();
604
+ if (!canProceed) {
605
+ const breaker = this.circuitBreaker;
606
+ if (typeof breaker.createOpenError === 'function') {
607
+ throw breaker.createOpenError();
608
+ }
609
+ throw new LinearApiError('Circuit breaker is open — API calls blocked', 503);
610
+ }
611
+ await this.rateLimiter.acquire();
612
+ const query = `
613
+ query SubIssueStatuses($id: String!) {
614
+ issue(id: $id) {
615
+ children(first: 50) {
616
+ nodes {
617
+ identifier
618
+ title
619
+ state { name }
620
+ }
621
+ }
622
+ }
623
+ }
624
+ `;
625
+ try {
626
+ const result = await this.client.client.rawRequest(query, { id: issueIdOrIdentifier });
627
+ await this.circuitBreaker.recordSuccess();
628
+ const data = result.data;
629
+ if (!data.issue) {
521
630
  throw new LinearApiError(`Issue not found: ${issueIdOrIdentifier}`, 404);
522
631
  }
523
- const children = await parentIssue.children();
524
- const results = [];
525
- for (const child of children.nodes) {
526
- const state = await child.state;
527
- results.push({
528
- identifier: child.identifier,
529
- title: child.title,
530
- status: state?.name ?? 'Unknown',
531
- });
632
+ return data.issue.children.nodes.map((child) => ({
633
+ identifier: child.identifier,
634
+ title: child.title,
635
+ status: child.state?.name ?? 'Unknown',
636
+ }));
637
+ }
638
+ catch (error) {
639
+ if (this.circuitBreaker.isAuthError(error)) {
640
+ const statusCode = extractAuthStatusCode(error);
641
+ await this.circuitBreaker.recordAuthFailure(statusCode);
532
642
  }
533
- return results;
534
- });
643
+ throw error;
644
+ }
535
645
  }
536
646
  /**
537
647
  * Get the repository URL associated with a project via its links or description
@@ -566,6 +676,10 @@ export class LinearAgentClient {
566
676
  /**
567
677
  * Get sub-issues with their blocking relations for dependency graph building
568
678
  *
679
+ * Uses a single raw GraphQL query instead of N+1 lazy-loaded SDK calls.
680
+ * Previous implementation made 2 + 4N + M API calls (where N = children,
681
+ * M = total relations). This version makes exactly 1 API call.
682
+ *
569
683
  * Builds a complete dependency graph of a parent issue's children, including
570
684
  * which sub-issues block which other sub-issues. This is used by the coordinator
571
685
  * agent to determine execution order.
@@ -574,66 +688,99 @@ export class LinearAgentClient {
574
688
  * @returns The sub-issue dependency graph
575
689
  */
576
690
  async getSubIssueGraph(issueIdOrIdentifier) {
577
- return this.withRetry(async () => {
578
- const parentIssue = await this.client.issue(issueIdOrIdentifier);
579
- if (!parentIssue) {
691
+ const canProceed = await this.circuitBreaker.canProceed();
692
+ if (!canProceed) {
693
+ const breaker = this.circuitBreaker;
694
+ if (typeof breaker.createOpenError === 'function') {
695
+ throw breaker.createOpenError();
696
+ }
697
+ throw new LinearApiError('Circuit breaker is open — API calls blocked', 503);
698
+ }
699
+ await this.rateLimiter.acquire();
700
+ const query = `
701
+ query SubIssueGraph($id: String!) {
702
+ issue(id: $id) {
703
+ id
704
+ identifier
705
+ children(first: 50) {
706
+ nodes {
707
+ id
708
+ identifier
709
+ title
710
+ description
711
+ priority
712
+ url
713
+ state { name }
714
+ labels(first: 20) { nodes { name } }
715
+ relations(first: 50) {
716
+ nodes {
717
+ type
718
+ relatedIssue { id identifier }
719
+ }
720
+ }
721
+ inverseRelations(first: 50) {
722
+ nodes {
723
+ type
724
+ issue { id identifier }
725
+ }
726
+ }
727
+ }
728
+ }
729
+ }
730
+ }
731
+ `;
732
+ try {
733
+ const result = await this.client.client.rawRequest(query, { id: issueIdOrIdentifier });
734
+ await this.circuitBreaker.recordSuccess();
735
+ const data = result.data;
736
+ if (!data.issue) {
580
737
  throw new LinearApiError(`Issue not found: ${issueIdOrIdentifier}`, 404);
581
738
  }
582
- const children = await parentIssue.children();
583
- const subIssueIds = new Set(children.nodes.map((c) => c.id));
584
- const subIssueIdentifiers = new Map();
585
- // Build identifier map for all sub-issues
586
- for (const child of children.nodes) {
587
- subIssueIdentifiers.set(child.id, child.identifier);
588
- }
589
- const graphNodes = [];
590
- for (const child of children.nodes) {
591
- const state = await child.state;
592
- const labels = await child.labels();
593
- // Get relations to find blocking dependencies
594
- const relations = await child.relations();
595
- const inverseRelations = await child.inverseRelations();
739
+ const parentIssue = data.issue;
740
+ const subIssueIds = new Set(parentIssue.children.nodes.map((c) => c.id));
741
+ const graphNodes = parentIssue.children.nodes.map((child) => {
596
742
  const blockedBy = [];
597
743
  const blocks = [];
598
- // Check inverse relations - other issues blocking this one
599
- for (const rel of inverseRelations.nodes) {
600
- if (rel.type === 'blocks') {
601
- const sourceIssue = await rel.issue;
602
- if (sourceIssue && subIssueIds.has(sourceIssue.id)) {
603
- blockedBy.push(sourceIssue.identifier);
604
- }
744
+ // Inverse relations: other issues blocking this one
745
+ for (const rel of child.inverseRelations.nodes) {
746
+ if (rel.type === 'blocks' && rel.issue && subIssueIds.has(rel.issue.id)) {
747
+ blockedBy.push(rel.issue.identifier);
605
748
  }
606
749
  }
607
- // Check outgoing relations - this issue blocking others
608
- for (const rel of relations.nodes) {
609
- if (rel.type === 'blocks') {
610
- const relatedIssue = await rel.relatedIssue;
611
- if (relatedIssue && subIssueIds.has(relatedIssue.id)) {
612
- blocks.push(relatedIssue.identifier);
613
- }
750
+ // Outgoing relations: this issue blocking others
751
+ for (const rel of child.relations.nodes) {
752
+ if (rel.type === 'blocks' && rel.relatedIssue && subIssueIds.has(rel.relatedIssue.id)) {
753
+ blocks.push(rel.relatedIssue.identifier);
614
754
  }
615
755
  }
616
- graphNodes.push({
756
+ return {
617
757
  issue: {
618
758
  id: child.id,
619
759
  identifier: child.identifier,
620
760
  title: child.title,
621
761
  description: child.description ?? undefined,
622
- status: state?.name,
762
+ status: child.state?.name,
623
763
  priority: child.priority,
624
- labels: labels.nodes.map((l) => l.name),
764
+ labels: child.labels.nodes.map((l) => l.name),
625
765
  url: child.url,
626
766
  },
627
767
  blockedBy,
628
768
  blocks,
629
- });
630
- }
769
+ };
770
+ });
631
771
  return {
632
772
  parentId: parentIssue.id,
633
773
  parentIdentifier: parentIssue.identifier,
634
774
  subIssues: graphNodes,
635
775
  };
636
- });
776
+ }
777
+ catch (error) {
778
+ if (this.circuitBreaker.isAuthError(error)) {
779
+ const statusCode = extractAuthStatusCode(error);
780
+ await this.circuitBreaker.recordAuthFailure(statusCode);
781
+ }
782
+ throw error;
783
+ }
637
784
  }
638
785
  }
639
786
  /**
@@ -642,3 +789,27 @@ export class LinearAgentClient {
642
789
  export function createLinearAgentClient(config) {
643
790
  return new LinearAgentClient(config);
644
791
  }
792
+ // ---------------------------------------------------------------------------
793
+ // Helpers
794
+ // ---------------------------------------------------------------------------
795
+ /**
796
+ * Extract HTTP status code from an error for circuit breaker recording.
797
+ */
798
+ function extractAuthStatusCode(error) {
799
+ if (typeof error !== 'object' || error === null)
800
+ return 0;
801
+ const err = error;
802
+ if (typeof err.status === 'number')
803
+ return err.status;
804
+ if (typeof err.statusCode === 'number')
805
+ return err.statusCode;
806
+ const response = err.response;
807
+ if (response) {
808
+ if (typeof response.status === 'number')
809
+ return response.status;
810
+ if (typeof response.statusCode === 'number')
811
+ return response.statusCode;
812
+ }
813
+ // Default to 400 for auth errors detected by message pattern
814
+ return 400;
815
+ }