@chappibunny/repolens 1.5.0 → 1.5.1
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 +17 -0
- package/package.json +1 -1
- package/src/ai/prompts.js +129 -36
- package/src/docs/generate-doc-set.js +17 -9
- package/src/publishers/index.js +24 -8
- package/src/utils/retry.js +18 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to RepoLens will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 1.5.1
|
|
6
|
+
|
|
7
|
+
### 🐛 Bug Fixes
|
|
8
|
+
|
|
9
|
+
- **[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.
|
|
10
|
+
|
|
11
|
+
### 🔧 Improvements — Robustness
|
|
12
|
+
|
|
13
|
+
- **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.
|
|
14
|
+
- **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.
|
|
15
|
+
- **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.
|
|
16
|
+
|
|
17
|
+
### 📊 Test Coverage
|
|
18
|
+
|
|
19
|
+
- **251 tests** passing across **18 test files** (up from 224/17).
|
|
20
|
+
- 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.
|
|
21
|
+
|
|
5
22
|
## 1.5.0
|
|
6
23
|
|
|
7
24
|
### 🚀 New Features (Tier 3 — Differentiation)
|
package/package.json
CHANGED
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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${
|
|
429
|
-
md += `## Dominant Domains\n\n${
|
|
430
|
-
md += `## Main Technology Patterns\n\n${
|
|
431
|
-
md += `## Key Observations\n\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))
|
|
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
|
-
|
|
440
|
-
|
|
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:** ${
|
|
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${
|
|
451
|
-
md += `## Architectural Strengths\n\n${
|
|
452
|
-
md += `## Architectural Weaknesses\n\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))
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
if (flow.
|
|
463
|
-
|
|
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${
|
|
472
|
-
md += `## Core Product Flows\n\n${
|
|
473
|
-
if (d.importantRoutes) md += `## Important Routes\n\n${
|
|
474
|
-
if (d.sharedLibraries) md += `## Important Shared Libraries\n\n${
|
|
475
|
-
md += `## Known Complexity Hotspots\n\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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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);
|
package/src/publishers/index.js
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
}
|
package/src/utils/retry.js
CHANGED
|
@@ -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,7 +22,16 @@ export async function fetchWithRetry(url, options = {}, config = {}) {
|
|
|
21
22
|
|
|
22
23
|
while (attempt <= retries) {
|
|
23
24
|
try {
|
|
24
|
-
|
|
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;
|
|
@@ -39,8 +49,13 @@ export async function fetchWithRetry(url, options = {}, config = {}) {
|
|
|
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: ${
|
|
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
|
|