@chappibunny/repolens 1.5.0 โ†’ 1.5.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,47 @@
2
2
 
3
3
  All notable changes to RepoLens will be documented in this file.
4
4
 
5
+ ## 1.5.3
6
+
7
+ ### ๐Ÿ”ง Improvements
8
+
9
+ - **CI test gate**: Publish workflow now runs the full Vitest suite before publishing. The `publish` job requires both `security` and `test` jobs to pass.
10
+
11
+ ### ๐Ÿงช Test Coverage
12
+
13
+ - **Renderer unit tests** (`tests/renderers.test.js`): 33 tests covering all four `render.js` exports (`renderSystemOverview`, `renderModuleCatalog`, `renderApiSurface`, `renderRouteMap`), `renderSystemMap` (with/without depGraph, categories, limits), and `renderDiff.js` (`buildArchitectureDiffData` route detection, `renderArchitectureDiff` truncation).
14
+ - **311 tests** passing across **21 test files** (up from 278/20).
15
+
16
+ ## 1.5.2
17
+
18
+ ### ๐Ÿ› Bug Fixes
19
+
20
+ - **retry-after: 0 honoured**: Fixed falsy-zero bug in `fetchWithRetry` where `retry-after: 0` header was ignored (fell through to `baseDelayMs`). Now properly honours the server's hint, even when it's zero.
21
+
22
+ ### ๐Ÿงช Test Quality
23
+
24
+ - **Mock HTTP server integration tests** (`tests/http-integration.test.js`): 17 tests exercising `fetchWithRetry` against a real local HTTP server โ€” retry on 429/500, timeout handling, header validation, Notion/Confluence block/format generation.
25
+ - **Rate-limit concurrent stress tests** (`tests/rate-limit-stress.test.js`): 9 tests firing 20โ€“50 parallel requests through the token bucket to verify throttling, deadlock freedom, burst behaviour, and `batchRequests` concurrency control.
26
+ - **Watch mode test refactor** (`tests/watch.test.js`): Replaced fake timers with real temp directories and real `fs.watch` events. Tests now create actual files, observe filesystem watcher callbacks, and verify debounced rebuilds without `vi.useFakeTimers()`.
27
+ - **278 tests** passing across **20 test files** (up from 251/18).
28
+
29
+ ## 1.5.1
30
+
31
+ ### ๐Ÿ› Bug Fixes
32
+
33
+ - **[object Object] rendering fix**: All 6 structured AI renderers now use safe coercion helpers (`safeStr`, `toBulletList`, `toHeadingSections`) that handle strings, arrays, objects, and null values robustly. Prevents `[object Object]` from appearing in Notion/Confluence/Markdown output when AI returns unexpected shapes.
34
+
35
+ ### ๐Ÿ”ง Improvements โ€” Robustness
36
+
37
+ - **Fetch timeout**: All HTTP requests via `fetchWithRetry` now enforce a 30-second timeout (configurable via `timeoutMs`). Prevents publishers from hanging indefinitely on stalled connections. `AbortError` is converted to a friendly timeout message.
38
+ - **Per-document error isolation**: Extended analysis (GraphQL, TypeScript, dependency graph, drift detection) now wraps each phase in try/catch. A failing analyzer no longer blocks the entire doc generation pipeline.
39
+ - **Partial-success publishing**: Publisher orchestration no longer throws on the first failure. If Notion fails but Confluence/Markdown succeed, all remaining publishers still run. Failures are logged as warnings. Only throws if *all* publishers fail.
40
+
41
+ ### ๐Ÿ“Š Test Coverage
42
+
43
+ - **251 tests** passing across **18 test files** (up from 224/17).
44
+ - New `tests/robustness.test.js` with 27 tests covering: rate limiter, context builder, flow inference, Discord integration, PR comment module, telemetry, write-doc-set file I/O, fetch timeout, doc generation error isolation, and partial-success publishing.
45
+
5
46
  ## 1.5.0
6
47
 
7
48
  ### ๐Ÿš€ New Features (Tier 3 โ€” Differentiation)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chappibunny/repolens",
3
- "version": "1.5.0",
3
+ "version": "1.5.3",
4
4
  "description": "AI-assisted documentation intelligence system for technical and non-technical audiences",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/ai/prompts.js CHANGED
@@ -405,73 +405,166 @@ export function renderStructuredToMarkdown(key, parsed) {
405
405
  }
406
406
  }
407
407
 
408
+ /**
409
+ * Safely convert any AI response value to a readable string.
410
+ * Handles: strings, arrays (of strings or objects), plain objects, and other types.
411
+ */
412
+ function safeStr(val) {
413
+ if (val == null) return "";
414
+ if (typeof val === "string") return val;
415
+ if (Array.isArray(val)) return val.map(safeStr).join(", ");
416
+ if (typeof val === "object") {
417
+ // Try common field patterns the AI might use
418
+ if (val.name) return val.description ? `${val.name}: ${val.description}` : val.name;
419
+ if (val.title) return val.description ? `${val.title}: ${val.description}` : val.title;
420
+ // Fallback: render object key/value pairs
421
+ return Object.entries(val).map(([k, v]) => `${k}: ${typeof v === "string" ? v : safeStr(v)}`).join("; ");
422
+ }
423
+ return String(val);
424
+ }
425
+
426
+ /**
427
+ * Convert a value to a bullet list.
428
+ * Handles strings, arrays of strings, arrays of objects, and plain objects.
429
+ */
430
+ function toBulletList(val) {
431
+ if (val == null) return "";
432
+ if (typeof val === "string") return val;
433
+ if (Array.isArray(val)) {
434
+ return val.map(item => {
435
+ if (typeof item === "string") return `- ${item}`;
436
+ if (typeof item === "object" && item !== null) {
437
+ const label = item.name || item.title || Object.keys(item)[0] || "";
438
+ const desc = item.description || item[Object.keys(item)[0]];
439
+ if (label && desc && label !== desc) return `- **${label}**: ${desc}`;
440
+ return `- ${safeStr(item)}`;
441
+ }
442
+ return `- ${String(item)}`;
443
+ }).join("\n");
444
+ }
445
+ if (typeof val === "object") {
446
+ // Object with key/value pairs โ†’ render as list
447
+ return Object.entries(val).map(([k, v]) => `- **${k}**: ${typeof v === "string" ? v : safeStr(v)}`).join("\n");
448
+ }
449
+ return String(val);
450
+ }
451
+
452
+ /**
453
+ * Convert a value to a heading-based section list (### heading per item).
454
+ */
455
+ function toHeadingSections(val) {
456
+ if (val == null) return "";
457
+ if (typeof val === "string") return val;
458
+ if (Array.isArray(val)) {
459
+ return val.map(item => {
460
+ if (typeof item === "string") return `### ${item}`;
461
+ if (typeof item === "object" && item !== null) {
462
+ const label = item.name || item.title || Object.keys(item)[0] || "Section";
463
+ const desc = item.description || item[Object.keys(item)[0]] || "";
464
+ return `### ${label}\n\n${typeof desc === "string" ? desc : safeStr(desc)}`;
465
+ }
466
+ return `### ${String(item)}`;
467
+ }).join("\n\n");
468
+ }
469
+ if (typeof val === "object") {
470
+ return Object.entries(val).map(([k, v]) => `### ${k}\n\n${typeof v === "string" ? v : safeStr(v)}`).join("\n\n");
471
+ }
472
+ return String(val);
473
+ }
474
+
408
475
  function renderExecutiveSummaryJSON(d) {
409
476
  let md = `# Executive Summary\n\n`;
410
- md += `## What This System Does\n\n${d.whatItDoes}\n\n`;
411
- md += `## Who It Serves\n\n${d.whoItServes}\n\n`;
412
- md += `## Core Capabilities\n\n`;
413
- if (Array.isArray(d.coreCapabilities)) {
414
- md += d.coreCapabilities.map(c => `- ${c}`).join("\n") + "\n\n";
415
- } else {
416
- md += `${d.coreCapabilities}\n\n`;
417
- }
418
- md += `## Main System Areas\n\n${Array.isArray(d.mainAreas) ? d.mainAreas.map(a => `- **${a.name || a}**${a.description ? `: ${a.description}` : ""}`).join("\n") : d.mainAreas}\n\n`;
419
- if (d.dependencies) md += `## Key Dependencies\n\n${Array.isArray(d.dependencies) ? d.dependencies.map(dep => `- ${dep}`).join("\n") : d.dependencies}\n\n`;
420
- md += `## Operational and Architectural Risks\n\n${Array.isArray(d.risks) ? d.risks.map(r => `- ${r}`).join("\n") : d.risks}\n\n`;
421
- if (d.focusAreas) md += `## Recommended Focus Areas\n\n${Array.isArray(d.focusAreas) ? d.focusAreas.map(f => `- ${f}`).join("\n") : d.focusAreas}\n`;
477
+ md += `## What This System Does\n\n${safeStr(d.whatItDoes)}\n\n`;
478
+ md += `## Who It Serves\n\n${safeStr(d.whoItServes)}\n\n`;
479
+ md += `## Core Capabilities\n\n${toBulletList(d.coreCapabilities)}\n\n`;
480
+ md += `## Main System Areas\n\n${toBulletList(d.mainAreas)}\n\n`;
481
+ if (d.dependencies) md += `## Key Dependencies\n\n${toBulletList(d.dependencies)}\n\n`;
482
+ md += `## Operational and Architectural Risks\n\n${toBulletList(d.risks)}\n\n`;
483
+ if (d.focusAreas) md += `## Recommended Focus Areas\n\n${toBulletList(d.focusAreas)}\n`;
422
484
  return md;
423
485
  }
424
486
 
425
487
  function renderSystemOverviewJSON(d) {
426
488
  let md = `# System Overview\n\n`;
427
- md += `## Repository Snapshot\n\n${d.snapshot}\n\n`;
428
- md += `## Main Architectural Layers\n\n${Array.isArray(d.layers) ? d.layers.map(l => `- **${l.name || l}**${l.description ? `: ${l.description}` : ""}`).join("\n") : d.layers}\n\n`;
429
- md += `## Dominant Domains\n\n${Array.isArray(d.domains) ? d.domains.map(dom => `- ${dom}`).join("\n") : d.domains}\n\n`;
430
- md += `## Main Technology Patterns\n\n${Array.isArray(d.patterns) ? d.patterns.map(p => `- ${p}`).join("\n") : d.patterns}\n\n`;
431
- md += `## Key Observations\n\n${Array.isArray(d.observations) ? d.observations.map(o => `- ${o}`).join("\n") : d.observations}\n`;
489
+ md += `## Repository Snapshot\n\n${safeStr(d.snapshot)}\n\n`;
490
+ md += `## Main Architectural Layers\n\n${toBulletList(d.layers)}\n\n`;
491
+ md += `## Dominant Domains\n\n${toBulletList(d.domains)}\n\n`;
492
+ md += `## Main Technology Patterns\n\n${toBulletList(d.patterns)}\n\n`;
493
+ md += `## Key Observations\n\n${toBulletList(d.observations)}\n`;
432
494
  return md;
433
495
  }
434
496
 
435
497
  function renderBusinessDomainsJSON(d) {
436
498
  let md = `# Business Domains\n\n`;
437
- if (!Array.isArray(d.domains)) return md + d.domains;
499
+ if (!Array.isArray(d.domains)) {
500
+ // Handle object-style domains: { "Auth": { description: "..." }, ... }
501
+ if (typeof d.domains === "object" && d.domains !== null) {
502
+ for (const [name, info] of Object.entries(d.domains)) {
503
+ const desc = typeof info === "string" ? info : info?.description || safeStr(info);
504
+ md += `## ${name}\n\n${desc}\n\n`;
505
+ if (info?.modules) md += `**Key modules:** ${safeStr(info.modules)}\n\n`;
506
+ if (info?.userFunctionality) md += `**User-visible functionality:** ${info.userFunctionality}\n\n`;
507
+ if (info?.dependencies) md += `**Dependencies:** ${safeStr(info.dependencies)}\n\n`;
508
+ }
509
+ return md;
510
+ }
511
+ return md + safeStr(d.domains);
512
+ }
438
513
  for (const dom of d.domains) {
439
- md += `## ${dom.name}\n\n${dom.description || ""}\n\n`;
440
- if (dom.modules) md += `**Key modules:** ${Array.isArray(dom.modules) ? dom.modules.join(", ") : dom.modules}\n\n`;
514
+ const name = dom.name || dom.title || safeStr(dom);
515
+ md += `## ${name}\n\n${dom.description || ""}\n\n`;
516
+ if (dom.modules) md += `**Key modules:** ${safeStr(dom.modules)}\n\n`;
441
517
  if (dom.userFunctionality) md += `**User-visible functionality:** ${dom.userFunctionality}\n\n`;
442
- if (dom.dependencies) md += `**Dependencies:** ${Array.isArray(dom.dependencies) ? dom.dependencies.join(", ") : dom.dependencies}\n\n`;
518
+ if (dom.dependencies) md += `**Dependencies:** ${safeStr(dom.dependencies)}\n\n`;
443
519
  }
444
520
  return md;
445
521
  }
446
522
 
447
523
  function renderArchitectureOverviewJSON(d) {
448
524
  let md = `# Architecture Overview\n\n`;
449
- md += `## Architecture Style\n\n${d.style}\n\n`;
450
- md += `## Layers\n\n${Array.isArray(d.layers) ? d.layers.map(l => `### ${l.name || l}\n\n${l.description || ""}`).join("\n\n") : d.layers}\n\n`;
451
- md += `## Architectural Strengths\n\n${Array.isArray(d.strengths) ? d.strengths.map(s => `- ${s}`).join("\n") : d.strengths}\n\n`;
452
- md += `## Architectural Weaknesses\n\n${Array.isArray(d.weaknesses) ? d.weaknesses.map(w => `- ${w}`).join("\n") : d.weaknesses}\n`;
525
+ md += `## Architecture Style\n\n${safeStr(d.style)}\n\n`;
526
+ md += `## Layers\n\n${toHeadingSections(d.layers)}\n\n`;
527
+ md += `## Architectural Strengths\n\n${toBulletList(d.strengths)}\n\n`;
528
+ md += `## Architectural Weaknesses\n\n${toBulletList(d.weaknesses)}\n`;
453
529
  return md;
454
530
  }
455
531
 
456
532
  function renderDataFlowsJSON(d) {
457
533
  let md = `# Data Flows\n\n`;
458
- if (!Array.isArray(d.flows)) return md + d.flows;
534
+ if (!Array.isArray(d.flows)) {
535
+ if (typeof d.flows === "object" && d.flows !== null) {
536
+ for (const [name, info] of Object.entries(d.flows)) {
537
+ const desc = typeof info === "string" ? info : info?.description || safeStr(info);
538
+ md += `## ${name}\n\n${desc}\n\n`;
539
+ if (info?.steps) md += `**Steps:**\n${toBulletList(info.steps)}\n\n`;
540
+ if (info?.modules) md += `**Involved modules:** ${safeStr(info.modules)}\n\n`;
541
+ }
542
+ return md;
543
+ }
544
+ return md + safeStr(d.flows);
545
+ }
459
546
  for (const flow of d.flows) {
460
- md += `## ${flow.name}\n\n${flow.description || ""}\n\n`;
461
- if (flow.steps) md += `**Steps:**\n${Array.isArray(flow.steps) ? flow.steps.map((s, i) => `${i + 1}. ${s}`).join("\n") : flow.steps}\n\n`;
462
- if (flow.modules) md += `**Involved modules:** ${Array.isArray(flow.modules) ? flow.modules.join(", ") : flow.modules}\n\n`;
463
- if (flow.criticalDependencies) md += `**Critical dependencies:** ${flow.criticalDependencies}\n\n`;
547
+ const name = flow.name || flow.title || safeStr(flow);
548
+ md += `## ${name}\n\n${flow.description || ""}\n\n`;
549
+ if (flow.steps) {
550
+ const steps = Array.isArray(flow.steps)
551
+ ? flow.steps.map((s, i) => `${i + 1}. ${safeStr(s)}`).join("\n")
552
+ : safeStr(flow.steps);
553
+ md += `**Steps:**\n${steps}\n\n`;
554
+ }
555
+ if (flow.modules) md += `**Involved modules:** ${safeStr(flow.modules)}\n\n`;
556
+ if (flow.criticalDependencies) md += `**Critical dependencies:** ${safeStr(flow.criticalDependencies)}\n\n`;
464
557
  }
465
558
  return md;
466
559
  }
467
560
 
468
561
  function renderDeveloperOnboardingJSON(d) {
469
562
  let md = `# Developer Onboarding\n\n`;
470
- md += `## Start Here\n\n${d.startHere}\n\n`;
471
- md += `## Main Folders\n\n${Array.isArray(d.mainFolders) ? d.mainFolders.map(f => `- **${f.name || f}**${f.description ? `: ${f.description}` : ""}`).join("\n") : d.mainFolders}\n\n`;
472
- md += `## Core Product Flows\n\n${Array.isArray(d.coreFlows) ? d.coreFlows.map(f => `- ${f}`).join("\n") : d.coreFlows}\n\n`;
473
- if (d.importantRoutes) md += `## Important Routes\n\n${Array.isArray(d.importantRoutes) ? d.importantRoutes.map(r => `- ${r}`).join("\n") : d.importantRoutes}\n\n`;
474
- if (d.sharedLibraries) md += `## Important Shared Libraries\n\n${Array.isArray(d.sharedLibraries) ? d.sharedLibraries.map(l => `- ${l}`).join("\n") : d.sharedLibraries}\n\n`;
475
- md += `## Known Complexity Hotspots\n\n${Array.isArray(d.complexityHotspots) ? d.complexityHotspots.map(h => `- ${h}`).join("\n") : d.complexityHotspots}\n`;
563
+ md += `## Start Here\n\n${safeStr(d.startHere)}\n\n`;
564
+ md += `## Main Folders\n\n${toBulletList(d.mainFolders)}\n\n`;
565
+ md += `## Core Product Flows\n\n${toBulletList(d.coreFlows)}\n\n`;
566
+ if (d.importantRoutes) md += `## Important Routes\n\n${toBulletList(d.importantRoutes)}\n\n`;
567
+ if (d.sharedLibraries) md += `## Important Shared Libraries\n\n${toBulletList(d.sharedLibraries)}\n\n`;
568
+ md += `## Known Complexity Hotspots\n\n${toBulletList(d.complexityHotspots)}\n`;
476
569
  return md;
477
570
  }
@@ -27,7 +27,7 @@ import {
27
27
  renderDependencyGraph,
28
28
  renderArchitectureDrift as renderDriftReport
29
29
  } from "../renderers/renderAnalysis.js";
30
- import { info } from "../utils/logger.js";
30
+ import { info, warn } from "../utils/logger.js";
31
31
  import path from "node:path";
32
32
 
33
33
  export async function generateDocumentSet(scanResult, config, diffData = null, pluginManager = null) {
@@ -43,17 +43,25 @@ export async function generateDocumentSet(scanResult, config, diffData = null, p
43
43
  const scanFiles = scanResult._files || [];
44
44
 
45
45
  info("Running extended analysis...");
46
- const graphqlResult = await analyzeGraphQL(scanFiles, repoRoot);
47
- const tsResult = await analyzeTypeScript(scanFiles, repoRoot);
48
- const depGraph = await analyzeDependencyGraph(scanFiles, repoRoot);
46
+ let graphqlResult = { detected: false };
47
+ let tsResult = { detected: false };
48
+ let depGraph = { stats: {}, graph: {} };
49
+ try { graphqlResult = await analyzeGraphQL(scanFiles, repoRoot); } catch (e) { warn(`GraphQL analysis failed: ${e.message}`); }
50
+ try { tsResult = await analyzeTypeScript(scanFiles, repoRoot); } catch (e) { warn(`TypeScript analysis failed: ${e.message}`); }
51
+ try { depGraph = await analyzeDependencyGraph(scanFiles, repoRoot); } catch (e) { warn(`Dependency graph analysis failed: ${e.message}`); }
49
52
 
50
53
  // Architecture drift detection
51
54
  const outputDir = path.join(repoRoot, ".repolens");
52
- const baseline = await loadBaseline(outputDir);
53
- const snapshot = buildSnapshot(scanResult, depGraph, graphqlResult, tsResult);
54
- const driftResult = detectDrift(baseline, snapshot);
55
- // Save current snapshot as new baseline
56
- await saveBaseline(snapshot, outputDir);
55
+ let baseline = null;
56
+ let snapshot = null;
57
+ let driftResult = { drifts: [], summary: "No drift data available" };
58
+ try {
59
+ baseline = await loadBaseline(outputDir);
60
+ snapshot = buildSnapshot(scanResult, depGraph, graphqlResult, tsResult);
61
+ driftResult = detectDrift(baseline, snapshot);
62
+ // Save current snapshot as new baseline
63
+ await saveBaseline(snapshot, outputDir);
64
+ } catch (e) { warn(`Drift detection failed: ${e.message}`); }
57
65
 
58
66
  // CODEOWNERS integration
59
67
  const codeowners = await parseCodeowners(repoRoot);
@@ -22,6 +22,7 @@ export async function publishDocs(cfg, renderedPages, scanResult, pluginManager
22
22
  const publishers = cfg.publishers || ["markdown", "notion"];
23
23
  const currentBranch = getCurrentBranch();
24
24
  const publishedTo = [];
25
+ const publishErrors = [];
25
26
  let publishStatus = "success";
26
27
  let notionUrl = null;
27
28
 
@@ -49,8 +50,9 @@ export async function publishDocs(cfg, renderedPages, scanResult, pluginManager
49
50
  notionUrl = `https://notion.so/${process.env.NOTION_PARENT_PAGE_ID}`;
50
51
  }
51
52
  } catch (err) {
52
- publishStatus = "failure";
53
- throw err;
53
+ warn(`Notion publish failed: ${err.message}`);
54
+ publishErrors.push({ publisher: "notion", error: err });
55
+ publishStatus = "partial";
54
56
  }
55
57
  } else {
56
58
  const allowedBranches = cfg.notion?.branches?.join(", ") || "none configured";
@@ -70,8 +72,9 @@ export async function publishDocs(cfg, renderedPages, scanResult, pluginManager
70
72
  await publishToConfluence(cfg, pagesForAPIs);
71
73
  publishedTo.push("confluence");
72
74
  } catch (err) {
73
- publishStatus = "failure";
74
- throw err;
75
+ warn(`Confluence publish failed: ${err.message}`);
76
+ publishErrors.push({ publisher: "confluence", error: err });
77
+ publishStatus = "partial";
75
78
  }
76
79
  } else {
77
80
  const allowedBranches = cfg.confluence?.branches?.join(", ") || "none configured";
@@ -86,8 +89,9 @@ export async function publishDocs(cfg, renderedPages, scanResult, pluginManager
86
89
  await publishToMarkdown(cfg, renderedPages);
87
90
  publishedTo.push("markdown");
88
91
  } catch (err) {
89
- publishStatus = "failure";
90
- throw err;
92
+ warn(`Markdown publish failed: ${err.message}`);
93
+ publishErrors.push({ publisher: "markdown", error: err });
94
+ publishStatus = "partial";
91
95
  }
92
96
  }
93
97
 
@@ -102,8 +106,9 @@ export async function publishDocs(cfg, renderedPages, scanResult, pluginManager
102
106
  await publishToGitHubWiki(cfg, pagesForAPIs);
103
107
  publishedTo.push("github_wiki");
104
108
  } catch (err) {
105
- publishStatus = "failure";
106
- throw err;
109
+ warn(`GitHub Wiki publish failed: ${err.message}`);
110
+ publishErrors.push({ publisher: "github_wiki", error: err });
111
+ publishStatus = "partial";
107
112
  }
108
113
  } else {
109
114
  const allowedBranches = cfg.github_wiki?.branches?.join(", ") || "none configured";
@@ -195,4 +200,15 @@ export async function publishDocs(cfg, renderedPages, scanResult, pluginManager
195
200
  if (pluginManager) {
196
201
  await pluginManager.runHook("afterPublish", { publishedTo, publishStatus });
197
202
  }
203
+
204
+ // Report partial failures
205
+ if (publishErrors.length > 0) {
206
+ const failedNames = publishErrors.map(e => e.publisher).join(", ");
207
+ if (publishedTo.length > 0) {
208
+ warn(`Partial publish: succeeded for [${publishedTo.join(", ")}], failed for [${failedNames}]`);
209
+ } else {
210
+ // All publishers failed โ€” throw the first error
211
+ throw publishErrors[0].error;
212
+ }
213
+ }
198
214
  }
@@ -13,7 +13,8 @@ export async function fetchWithRetry(url, options = {}, config = {}) {
13
13
  retries = 3,
14
14
  baseDelayMs = 500,
15
15
  maxDelayMs = 4000,
16
- label = "request"
16
+ label = "request",
17
+ timeoutMs = 30000
17
18
  } = config;
18
19
 
19
20
  let attempt = 0;
@@ -21,26 +22,40 @@ export async function fetchWithRetry(url, options = {}, config = {}) {
21
22
 
22
23
  while (attempt <= retries) {
23
24
  try {
24
- const response = await fetch(url, options);
25
+ // Apply timeout via AbortController
26
+ const controller = new AbortController();
27
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
28
+ const fetchOpts = { ...options, signal: controller.signal };
29
+ let response;
30
+ try {
31
+ response = await fetch(url, fetchOpts);
32
+ } finally {
33
+ clearTimeout(timer);
34
+ }
25
35
 
26
36
  if (!isRetryableStatus(response.status)) {
27
37
  return response;
28
38
  }
29
39
 
30
40
  const retryAfterHeader = response.headers.get("retry-after");
31
- const retryAfterMs = retryAfterHeader
41
+ const retryAfterMs = retryAfterHeader != null
32
42
  ? Number(retryAfterHeader) * 1000
33
43
  : null;
34
44
 
35
- const delay = retryAfterMs || Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
45
+ const delay = retryAfterMs != null ? retryAfterMs : Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
36
46
 
37
47
  warn(`${label} failed with retryable status ${response.status}. Retrying in ${delay}ms (attempt ${attempt + 1}/${retries + 1})`);
38
48
  await sleep(delay);
39
49
  } catch (error) {
40
50
  lastError = error;
41
51
 
52
+ // Convert AbortError to a friendlier timeout message
53
+ if (error.name === "AbortError") {
54
+ lastError = new Error(`${label} timed out after ${timeoutMs}ms`);
55
+ }
56
+
42
57
  const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
43
- warn(`${label} threw error: ${error.message}. Retrying in ${delay}ms (attempt ${attempt + 1}/${retries + 1})`);
58
+ warn(`${label} threw error: ${lastError.message}. Retrying in ${delay}ms (attempt ${attempt + 1}/${retries + 1})`);
44
59
  await sleep(delay);
45
60
  }
46
61