karajan-code 1.2.2 → 1.3.0

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.
@@ -9,6 +9,8 @@
9
9
  * Requires planning_game.api_url in config or PG_API_URL env var.
10
10
  */
11
11
 
12
+ import { withRetry, isTransientError } from "../utils/retry.js";
13
+
12
14
  const DEFAULT_API_URL = "http://localhost:3000/api";
13
15
  const DEFAULT_TIMEOUT_MS = 10000;
14
16
 
@@ -20,10 +22,20 @@ async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_M
20
22
  const controller = new AbortController();
21
23
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
22
24
  try {
23
- return await fetch(url, { ...options, signal: controller.signal });
25
+ const response = await fetch(url, { ...options, signal: controller.signal });
26
+ if (!response.ok) {
27
+ const err = new Error(`Planning Game API error: ${response.status} ${response.statusText}`);
28
+ err.httpStatus = response.status;
29
+ err.retryAfter = response.headers?.get?.("retry-after") || null;
30
+ throw err;
31
+ }
32
+ return response;
24
33
  } catch (error) {
34
+ if (error?.httpStatus) throw error;
25
35
  if (error?.name === "AbortError") {
26
- throw new Error(`Planning Game API timeout after ${timeoutMs}ms`);
36
+ const err = new Error(`Planning Game API timeout after ${timeoutMs}ms`);
37
+ err.httpStatus = 408;
38
+ throw err;
27
39
  }
28
40
  throw new Error(`Planning Game network error: ${error?.message || "unknown error"}`);
29
41
  } finally {
@@ -31,6 +43,13 @@ async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_M
31
43
  }
32
44
  }
33
45
 
46
+ async function fetchWithRetry(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_MS, retryOpts = {}) {
47
+ return withRetry(
48
+ () => fetchWithTimeout(url, options, timeoutMs),
49
+ { maxAttempts: 3, initialBackoffMs: 1000, ...retryOpts }
50
+ );
51
+ }
52
+
34
53
  async function parseJsonResponse(response) {
35
54
  try {
36
55
  return await response.json();
@@ -41,10 +60,7 @@ async function parseJsonResponse(response) {
41
60
 
42
61
  export async function fetchCard({ projectId, cardId, timeoutMs = DEFAULT_TIMEOUT_MS }) {
43
62
  const url = `${getApiUrl()}/projects/${encodeURIComponent(projectId)}/cards/${encodeURIComponent(cardId)}`;
44
- const response = await fetchWithTimeout(url, {}, timeoutMs);
45
- if (!response.ok) {
46
- throw new Error(`Planning Game API error: ${response.status} ${response.statusText}`);
47
- }
63
+ const response = await fetchWithRetry(url, {}, timeoutMs);
48
64
  const data = await parseJsonResponse(response);
49
65
  return data?.card || data;
50
66
  }
@@ -55,27 +71,21 @@ export async function getCard({ projectId, cardId, timeoutMs = DEFAULT_TIMEOUT_M
55
71
 
56
72
  export async function listCards({ projectId, timeoutMs = DEFAULT_TIMEOUT_MS }) {
57
73
  const url = `${getApiUrl()}/projects/${encodeURIComponent(projectId)}/cards`;
58
- const response = await fetchWithTimeout(url, {}, timeoutMs);
59
- if (!response.ok) {
60
- throw new Error(`Planning Game API error: ${response.status} ${response.statusText}`);
61
- }
74
+ const response = await fetchWithRetry(url, {}, timeoutMs);
62
75
  const data = await parseJsonResponse(response);
63
76
  return data?.cards || data;
64
77
  }
65
78
 
66
79
  export async function updateCard({ projectId, cardId, firebaseId, updates, timeoutMs = DEFAULT_TIMEOUT_MS }) {
67
80
  const url = `${getApiUrl()}/projects/${encodeURIComponent(projectId)}/cards/${encodeURIComponent(firebaseId)}`;
68
- const response = await fetchWithTimeout(
81
+ const response = await fetchWithRetry(
69
82
  url,
70
83
  {
71
- method: "PATCH",
72
- headers: { "Content-Type": "application/json" },
73
- body: JSON.stringify({ updates })
84
+ method: "PATCH",
85
+ headers: { "Content-Type": "application/json" },
86
+ body: JSON.stringify({ updates })
74
87
  },
75
88
  timeoutMs
76
89
  );
77
- if (!response.ok) {
78
- throw new Error(`Planning Game API error: ${response.status} ${response.statusText}`);
79
- }
80
90
  return parseJsonResponse(response);
81
91
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Plugin loader: discovers and loads plugins from .karajan/plugins/ directories.
3
+ *
4
+ * Plugins are JS files that export a `register(api)` function.
5
+ * The `api` object provides: registerAgent, registerModel.
6
+ *
7
+ * Discovery order (all are loaded, not first-wins):
8
+ * 1. <project>/.karajan/plugins/*.js
9
+ * 2. ~/.karajan/plugins/*.js
10
+ */
11
+
12
+ import path from "node:path";
13
+ import { pathToFileURL } from "node:url";
14
+ import { getKarajanHome } from "../utils/paths.js";
15
+ import { registerAgent } from "../agents/index.js";
16
+
17
+ async function listPluginFiles(dir) {
18
+ try {
19
+ const { readdir } = await import("node:fs/promises");
20
+ const entries = await readdir(dir, { withFileTypes: true });
21
+ return entries
22
+ .filter((e) => e.isFile() && e.name.endsWith(".js"))
23
+ .map((e) => path.join(dir, e.name));
24
+ } catch {
25
+ return [];
26
+ }
27
+ }
28
+
29
+ async function loadPlugin(filePath, api, logger) {
30
+ try {
31
+ const mod = await import(pathToFileURL(filePath).href);
32
+ const registerFn = mod.register || mod.default?.register;
33
+ if (typeof registerFn !== "function") {
34
+ logger?.warn?.(`Plugin ${filePath}: no register() export found, skipping`);
35
+ return null;
36
+ }
37
+ const meta = registerFn(api);
38
+ const name = meta?.name || path.basename(filePath, ".js");
39
+ logger?.debug?.(`Plugin loaded: ${name} (${filePath})`);
40
+ return { name, path: filePath, meta };
41
+ } catch (error) {
42
+ logger?.warn?.(`Plugin ${filePath} failed to load: ${error.message}`);
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export async function loadPlugins({ projectDir, logger } = {}) {
48
+ const dirs = [];
49
+
50
+ if (projectDir) {
51
+ dirs.push(path.join(projectDir, ".karajan", "plugins"));
52
+ }
53
+ dirs.push(path.join(getKarajanHome(), "plugins"));
54
+
55
+ const api = { registerAgent };
56
+
57
+ const loaded = [];
58
+ for (const dir of dirs) {
59
+ const files = await listPluginFiles(dir);
60
+ for (const file of files) {
61
+ const result = await loadPlugin(file, api, logger);
62
+ if (result) loaded.push(result);
63
+ }
64
+ }
65
+
66
+ return loaded;
67
+ }
@@ -1,3 +1,54 @@
1
+ export function parsePlannerOutput(output) {
2
+ const text = String(output || "").trim();
3
+ if (!text) return null;
4
+
5
+ const lines = text
6
+ .split(/\r?\n/)
7
+ .map((line) => line.trim())
8
+ .filter(Boolean);
9
+
10
+ let title = null;
11
+ let approach = null;
12
+ const steps = [];
13
+
14
+ for (const line of lines) {
15
+ if (!title) {
16
+ const titleMatch = line.match(/^title\s*:\s*(.+)$/i);
17
+ if (titleMatch) {
18
+ title = titleMatch[1].trim();
19
+ continue;
20
+ }
21
+ }
22
+
23
+ if (!approach) {
24
+ const approachMatch = line.match(/^(approach|strategy)\s*:\s*(.+)$/i);
25
+ if (approachMatch) {
26
+ approach = approachMatch[2].trim();
27
+ continue;
28
+ }
29
+ }
30
+
31
+ const numberedStep = line.match(/^\d+[\).:-]\s*(.+)$/);
32
+ if (numberedStep) {
33
+ steps.push(numberedStep[1].trim());
34
+ continue;
35
+ }
36
+
37
+ const bulletStep = line.match(/^[-*]\s+(.+)$/);
38
+ if (bulletStep) {
39
+ steps.push(bulletStep[1].trim());
40
+ continue;
41
+ }
42
+ }
43
+
44
+ if (!title) {
45
+ const firstFreeLine = lines.find((line) => !/^(approach|strategy)\s*:/i.test(line) && !/^\d+[\).:-]\s*/.test(line));
46
+ title = firstFreeLine || null;
47
+ }
48
+
49
+ return { title, approach, steps };
50
+ }
51
+
1
52
  export function buildPlannerPrompt({ task, context }) {
2
53
  const parts = [
3
54
  "You are an expert software architect. Create an implementation plan for the following task.",
@@ -40,6 +40,17 @@ function parseThreshold(value) {
40
40
  return DEFAULT_THRESHOLD;
41
41
  }
42
42
 
43
+ export function getRepeatThreshold(config) {
44
+ const raw =
45
+ config?.failFast?.repeatThreshold ??
46
+ config?.session?.repeat_detection_threshold ??
47
+ config?.session?.fail_fast_repeats ??
48
+ 2;
49
+ const value = Number(raw);
50
+ if (Number.isFinite(value) && value > 0) return value;
51
+ return 2;
52
+ }
53
+
43
54
  export class RepeatDetector {
44
55
  constructor({ threshold = DEFAULT_THRESHOLD } = {}) {
45
56
  this.threshold = parseThreshold(threshold);
@@ -29,7 +29,8 @@ export class CoderRole extends BaseRole {
29
29
  reviewerFeedback: reviewerFeedback || null,
30
30
  sonarSummary: sonarSummary || null,
31
31
  coderRules: this.instructions,
32
- methodology: this.config?.development?.methodology || "tdd"
32
+ methodology: this.config?.development?.methodology || "tdd",
33
+ serenaEnabled: Boolean(this.config?.serena?.enabled)
33
34
  });
34
35
 
35
36
  const coderArgs = { prompt, role: "coder" };
@@ -41,6 +42,7 @@ export class CoderRole extends BaseRole {
41
42
  return {
42
43
  ok: false,
43
44
  result: {
45
+ ...result,
44
46
  error: result.error || result.output || "Coder failed",
45
47
  provider
46
48
  },
@@ -51,6 +53,7 @@ export class CoderRole extends BaseRole {
51
53
  return {
52
54
  ok: true,
53
55
  result: {
56
+ ...result,
54
57
  output: result.output || "",
55
58
  provider
56
59
  },
@@ -66,7 +66,7 @@ export class PlannerRole extends BaseRole {
66
66
  if (!result.ok) {
67
67
  return {
68
68
  ok: false,
69
- result: { error: result.error || result.output || "Planner agent failed", plan: null },
69
+ result: { ...result, error: result.error || result.output || "Planner agent failed", plan: null },
70
70
  summary: `Planner failed: ${result.error || "unknown error"}`
71
71
  };
72
72
  }
@@ -74,7 +74,7 @@ export class PlannerRole extends BaseRole {
74
74
  const plan = result.output?.trim() || "";
75
75
  return {
76
76
  ok: true,
77
- result: { plan, provider },
77
+ result: { ...result, plan, provider },
78
78
  summary: plan ? `Plan generated (${plan.split("\n").length} lines)` : "Empty plan generated"
79
79
  };
80
80
  }
@@ -47,6 +47,7 @@ export class RefactorerRole extends BaseRole {
47
47
  return {
48
48
  ok: false,
49
49
  result: {
50
+ ...result,
50
51
  error: result.error || result.output || "Refactorer failed",
51
52
  provider
52
53
  },
@@ -57,6 +58,7 @@ export class RefactorerRole extends BaseRole {
57
58
  return {
58
59
  ok: true,
59
60
  result: {
61
+ ...result,
60
62
  output: result.output?.trim() || "",
61
63
  provider
62
64
  },
@@ -101,29 +101,36 @@ export class ReviewerRole extends BaseRole {
101
101
 
102
102
  try {
103
103
  const parsed = parseReviewOutput(result.output);
104
- const approved = Boolean(parsed.approved);
105
104
  const blockingIssues = parsed.blocking_issues || [];
106
105
 
107
106
  return {
108
107
  ok: true,
109
108
  result: {
110
- approved,
109
+ ...result,
110
+ approved: parsed.approved,
111
111
  blocking_issues: blockingIssues,
112
112
  non_blocking_suggestions: parsed.non_blocking_suggestions || [],
113
113
  confidence: parsed.confidence ?? null,
114
114
  raw_summary: parsed.summary || ""
115
115
  },
116
- summary: approved
116
+ summary: parsed.approved
117
117
  ? `Approved: ${parsed.summary || "no issues found"}`
118
118
  : `Rejected: ${blockingIssues.length} blocking issue(s) — ${parsed.summary || ""}`
119
119
  };
120
120
  } catch (err) {
121
121
  return {
122
- ok: false,
122
+ ok: true,
123
123
  result: {
124
- error: `Failed to parse reviewer output: ${err.message}`,
124
+ ...result,
125
125
  approved: false,
126
- blocking_issues: []
126
+ blocking_issues: [{
127
+ id: "PARSE_ERROR",
128
+ severity: "high",
129
+ description: `Reviewer output could not be parsed: ${err.message}`
130
+ }],
131
+ non_blocking_suggestions: [],
132
+ confidence: 0,
133
+ raw_summary: `Parse error: ${err.message}`
127
134
  },
128
135
  summary: `Reviewer output parse error: ${err.message}`
129
136
  };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Automatic cleanup of expired sessions.
3
+ * Removes session directories older than session.expiry_days (default: 30).
4
+ */
5
+
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { getSessionRoot } from "./utils/paths.js";
9
+
10
+ const DEFAULT_EXPIRY_DAYS = 30;
11
+
12
+ export async function cleanupExpiredSessions({ config, logger } = {}) {
13
+ const expiryDays = config?.session?.expiry_days ?? DEFAULT_EXPIRY_DAYS;
14
+ if (expiryDays <= 0) return { removed: 0, errors: [] };
15
+
16
+ const sessionRoot = getSessionRoot();
17
+ const cutoff = Date.now() - expiryDays * 24 * 60 * 60 * 1000;
18
+
19
+ let entries;
20
+ try {
21
+ entries = await fs.readdir(sessionRoot, { withFileTypes: true });
22
+ } catch {
23
+ return { removed: 0, errors: [] };
24
+ }
25
+
26
+ const dirs = entries.filter((e) => e.isDirectory() && e.name.startsWith("s_"));
27
+ const removed = [];
28
+ const errors = [];
29
+
30
+ for (const dir of dirs) {
31
+ const sessionDir = path.join(sessionRoot, dir.name);
32
+ const sessionFile = path.join(sessionDir, "session.json");
33
+
34
+ try {
35
+ const raw = await fs.readFile(sessionFile, "utf8");
36
+ const session = JSON.parse(raw);
37
+ const updatedAt = new Date(session.updated_at || session.created_at).getTime();
38
+
39
+ if (updatedAt < cutoff) {
40
+ await fs.rm(sessionDir, { recursive: true, force: true });
41
+ removed.push(dir.name);
42
+ logger?.debug?.(`Session expired and removed: ${dir.name}`);
43
+ }
44
+ } catch (error) {
45
+ const stat = await fs.stat(sessionDir).catch(() => null);
46
+ if (stat && stat.mtimeMs < cutoff) {
47
+ try {
48
+ await fs.rm(sessionDir, { recursive: true, force: true });
49
+ removed.push(dir.name);
50
+ logger?.debug?.(`Orphan session dir removed: ${dir.name}`);
51
+ } catch (rmErr) {
52
+ errors.push({ session: dir.name, error: rmErr.message });
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+ if (removed.length > 0) {
59
+ logger?.info?.(`Cleaned up ${removed.length} expired session(s)`);
60
+ }
61
+
62
+ return { removed: removed.length, errors };
63
+ }
package/src/sonar/api.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { runCommand } from "../utils/process.js";
2
+ import { withRetry } from "../utils/retry.js";
2
3
  import { resolveSonarProjectKey } from "./project-key.js";
3
4
 
4
5
  export class SonarApiError extends Error {
@@ -22,16 +23,18 @@ function parseHttpResponse(stdout) {
22
23
  return { httpCode, body };
23
24
  }
24
25
 
25
- async function sonarFetch(config, urlPath) {
26
+ async function sonarFetchOnce(config, urlPath) {
26
27
  const token = tokenFromConfig(config);
27
28
  const url = `${config.sonarqube.host}${urlPath}`;
28
29
  const res = await runCommand("curl", ["-s", "-w", "\n%{http_code}", "-u", `${token}:`, url]);
29
30
 
30
31
  if (res.exitCode !== 0) {
31
- throw new SonarApiError(
32
+ const err = new SonarApiError(
32
33
  `SonarQube is not reachable at ${config.sonarqube.host}. Check that SonarQube is running ('kj sonar start').`,
33
34
  { url, hint: "Run 'kj sonar start' or verify Docker is running." }
34
35
  );
36
+ err.httpStatus = 503;
37
+ throw err;
35
38
  }
36
39
 
37
40
  const { httpCode, body } = parseHttpResponse(res.stdout);
@@ -44,15 +47,25 @@ async function sonarFetch(config, urlPath) {
44
47
  }
45
48
 
46
49
  if (httpCode >= 400) {
47
- throw new SonarApiError(
50
+ const err = new SonarApiError(
48
51
  `SonarQube API returned HTTP ${httpCode} for ${url}.`,
49
52
  { url, httpStatus: httpCode }
50
53
  );
54
+ err.httpStatus = httpCode;
55
+ throw err;
51
56
  }
52
57
 
53
58
  return body;
54
59
  }
55
60
 
61
+ async function sonarFetch(config, urlPath) {
62
+ const maxAttempts = config.sonarqube?.max_scan_retries ?? 3;
63
+ return withRetry(
64
+ () => sonarFetchOnce(config, urlPath),
65
+ { maxAttempts, initialBackoffMs: 2000, maxBackoffMs: 15000 }
66
+ );
67
+ }
68
+
56
69
  export async function getQualityGateStatus(config, projectKey = null) {
57
70
  const effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey });
58
71
  const body = await sonarFetch(config, `/api/qualitygates/project_status?projectKey=${effectiveProjectKey}`);
@@ -1,5 +1,35 @@
1
1
  import { calculateUsageCostUsd, DEFAULT_MODEL_PRICING, mergePricing } from "./pricing.js";
2
2
 
3
+ export function extractUsageMetrics(result, defaultModel = null) {
4
+ const usage = result?.usage || result?.metrics || {};
5
+ const tokens_in =
6
+ result?.tokens_in ??
7
+ usage?.tokens_in ??
8
+ usage?.input_tokens ??
9
+ usage?.prompt_tokens ??
10
+ 0;
11
+ const tokens_out =
12
+ result?.tokens_out ??
13
+ usage?.tokens_out ??
14
+ usage?.output_tokens ??
15
+ usage?.completion_tokens ??
16
+ 0;
17
+ const cost_usd =
18
+ result?.cost_usd ??
19
+ usage?.cost_usd ??
20
+ usage?.usd_cost ??
21
+ usage?.cost;
22
+ const model =
23
+ result?.model ??
24
+ usage?.model ??
25
+ usage?.model_name ??
26
+ usage?.model_id ??
27
+ defaultModel ??
28
+ null;
29
+
30
+ return { tokens_in, tokens_out, cost_usd, model };
31
+ }
32
+
3
33
  function toSafeNumber(value) {
4
34
  const n = Number(value);
5
35
  if (!Number.isFinite(n) || n < 0) return 0;
@@ -1,16 +1,6 @@
1
- export const DEFAULT_MODEL_PRICING = {
2
- "claude": { input_per_million: 3, output_per_million: 15 },
3
- "claude/sonnet": { input_per_million: 3, output_per_million: 15 },
4
- "claude/opus": { input_per_million: 15, output_per_million: 75 },
5
- "claude/haiku": { input_per_million: 0.25, output_per_million: 1.25 },
6
- "codex": { input_per_million: 1.5, output_per_million: 4 },
7
- "codex/o4-mini": { input_per_million: 1.5, output_per_million: 4 },
8
- "codex/o3": { input_per_million: 10, output_per_million: 40 },
9
- "gemini": { input_per_million: 1.25, output_per_million: 5 },
10
- "gemini/pro": { input_per_million: 1.25, output_per_million: 5 },
11
- "gemini/flash": { input_per_million: 0.075, output_per_million: 0.3 },
12
- "aider": { input_per_million: 3, output_per_million: 15 }
13
- };
1
+ import { buildDefaultPricingTable } from "../agents/model-registry.js";
2
+
3
+ export const DEFAULT_MODEL_PRICING = buildDefaultPricingTable();
14
4
 
15
5
  export function calculateUsageCostUsd({ model, tokens_in, tokens_out, pricing }) {
16
6
  const table = pricing || DEFAULT_MODEL_PRICING;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Generic retry utility with exponential backoff and jitter.
3
+ * Handles transient errors (429, 502, 503, timeouts) automatically.
4
+ */
5
+
6
+ const TRANSIENT_HTTP_CODES = new Set([408, 429, 500, 502, 503, 504]);
7
+
8
+ const TRANSIENT_ERROR_PATTERNS = [
9
+ "ETIMEDOUT", "ECONNREFUSED", "ECONNRESET", "EPIPE",
10
+ "ENETUNREACH", "EAI_AGAIN", "EHOSTUNREACH",
11
+ "socket hang up", "network error", "fetch failed"
12
+ ];
13
+
14
+ const DEFAULT_OPTIONS = {
15
+ maxAttempts: 3,
16
+ initialBackoffMs: 1000,
17
+ maxBackoffMs: 30000,
18
+ backoffMultiplier: 2,
19
+ jitterFactor: 0.1,
20
+ onRetry: null
21
+ };
22
+
23
+ export function isTransientError(error) {
24
+ if (!error) return false;
25
+
26
+ if (error.httpStatus && TRANSIENT_HTTP_CODES.has(error.httpStatus)) return true;
27
+ if (error.status && TRANSIENT_HTTP_CODES.has(error.status)) return true;
28
+
29
+ const msg = (error.message || String(error)).toLowerCase();
30
+ return TRANSIENT_ERROR_PATTERNS.some((p) => msg.includes(p.toLowerCase()));
31
+ }
32
+
33
+ export function parseRetryAfter(headerValue) {
34
+ if (!headerValue) return null;
35
+ const seconds = Number(headerValue);
36
+ if (!Number.isNaN(seconds) && seconds > 0) return seconds * 1000;
37
+
38
+ const date = Date.parse(headerValue);
39
+ if (!Number.isNaN(date)) {
40
+ const delayMs = date - Date.now();
41
+ return delayMs > 0 ? delayMs : null;
42
+ }
43
+ return null;
44
+ }
45
+
46
+ export function calculateBackoff(attempt, options = {}) {
47
+ const { initialBackoffMs = 1000, maxBackoffMs = 30000, backoffMultiplier = 2, jitterFactor = 0.1 } = options;
48
+
49
+ const base = initialBackoffMs * Math.pow(backoffMultiplier, attempt);
50
+ const capped = Math.min(base, maxBackoffMs);
51
+ const jitter = capped * jitterFactor * (Math.random() * 2 - 1);
52
+ return Math.max(0, Math.round(capped + jitter));
53
+ }
54
+
55
+ function sleep(ms) {
56
+ return new Promise((resolve) => setTimeout(resolve, ms));
57
+ }
58
+
59
+ export async function withRetry(fn, options = {}) {
60
+ const opts = { ...DEFAULT_OPTIONS, ...options };
61
+ let lastError;
62
+
63
+ for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
64
+ try {
65
+ return await fn(attempt);
66
+ } catch (error) {
67
+ lastError = error;
68
+
69
+ if (attempt >= opts.maxAttempts - 1) break;
70
+ if (!isTransientError(error)) break;
71
+
72
+ let delayMs = calculateBackoff(attempt, opts);
73
+
74
+ const retryAfterMs = parseRetryAfter(error.retryAfter || error.headers?.get?.("retry-after"));
75
+ if (retryAfterMs) {
76
+ delayMs = Math.min(retryAfterMs, opts.maxBackoffMs);
77
+ }
78
+
79
+ if (opts.onRetry) {
80
+ opts.onRetry({ attempt, error, delayMs, maxAttempts: opts.maxAttempts });
81
+ }
82
+
83
+ await sleep(delayMs);
84
+ }
85
+ }
86
+
87
+ throw lastError;
88
+ }