Skip to content

Commit 6df6bbe

Browse files
Merge pull request #45 from LeanMCP/kushagraagarwal525ka/mcp-269-fetch-bug-in-deployment
fetch bug in deployment
2 parents e410a1b + 4ec5cfb commit 6df6bbe

2 files changed

Lines changed: 99 additions & 241 deletions

File tree

package-lock.json

Lines changed: 1 addition & 235 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/src/commands/deploy.ts

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,90 @@ async function debugFetch(url: string, options: RequestInit = {}): Promise<Respo
4545
return response;
4646
}
4747

48+
/**
49+
* Retry wrapper for fetch operations with exponential backoff
50+
* Shows in-place retry counter updates without filling the terminal
51+
*
52+
* @param fetchFn - Function that performs the fetch operation
53+
* @param options - Configuration options
54+
* @param options.maxRetries - Maximum number of retry attempts after initial failure (default: 15). Total attempts = maxRetries + 1
55+
* @param options.initialDelay - Initial delay in ms before first retry (default: 1000)
56+
* @param options.maxDelay - Maximum delay in ms between retries (default: 10000)
57+
* @param options.operation - Name of the operation for display purposes (default: 'Fetch')
58+
* @param options.spinner - Optional ora spinner instance for UI updates
59+
* @param options.retryOnHttpErrors - Whether to retry on HTTP 5xx server errors. Default: false
60+
*
61+
* @throws Error if all retry attempts are exhausted
62+
*
63+
* @remarks
64+
* - Only retries on network errors by default (connection failures, timeouts, etc.)
65+
* - Set retryOnHttpErrors=true to also retry HTTP 5xx server errors (not 4xx client errors)
66+
* - Uses exponential backoff: 1s, 2s, 4s, 8s, 10s (max), 10s, ...
67+
* - With maxRetries=15: 1 initial attempt + 15 retries = 16 total attempts
68+
*/
69+
async function fetchWithRetry(
70+
fetchFn: () => Promise<Response>,
71+
options: {
72+
maxRetries?: number;
73+
initialDelay?: number;
74+
maxDelay?: number;
75+
operation?: string;
76+
spinner?: ReturnType<typeof ora>;
77+
retryOnHttpErrors?: boolean;
78+
} = {}
79+
): Promise<Response> {
80+
const maxRetries = options.maxRetries ?? 15;
81+
const initialDelay = options.initialDelay ?? 1000;
82+
const maxDelay = options.maxDelay ?? 10000;
83+
const operation = options.operation ?? 'Fetch';
84+
const spinner = options.spinner;
85+
const retryOnHttpErrors = options.retryOnHttpErrors ?? false;
86+
87+
let lastError: Error | null = null;
88+
89+
// Initial attempt (0) + retries (1 to maxRetries)
90+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
91+
try {
92+
const response = await fetchFn();
93+
94+
// Check if we should retry on HTTP errors
95+
if (retryOnHttpErrors && !response.ok && response.status >= 500) {
96+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
97+
}
98+
99+
return response;
100+
} catch (error) {
101+
lastError = error instanceof Error ? error : new Error(String(error));
102+
103+
if (attempt < maxRetries) {
104+
// Calculate delay with exponential backoff
105+
const delay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay);
106+
107+
// Show retry message - either via spinner or stdout
108+
const message = `${operation} failed. Retrying... (${attempt + 1}/${maxRetries})`;
109+
if (spinner) {
110+
spinner.text = message;
111+
} else {
112+
process.stdout.write('\r' + chalk.yellow(message));
113+
}
114+
115+
debug(`Retry ${attempt + 1}/${maxRetries}: ${lastError.message}, waiting ${delay}ms`);
116+
117+
await new Promise((resolve) => setTimeout(resolve, delay));
118+
}
119+
}
120+
}
121+
122+
// Clear the retry message if not using spinner
123+
if (!spinner) {
124+
process.stdout.write('\x1b[2K\r'); // ANSI escape to clear entire line
125+
}
126+
127+
throw new Error(
128+
`${operation} failed after ${maxRetries + 1} attempts (1 initial + ${maxRetries} retries): ${lastError?.message || 'Unknown error'}`
129+
);
130+
}
131+
48132
// API endpoints (relative to base URL)
49133
const API_ENDPOINTS = {
50134
// Projects
@@ -171,9 +255,13 @@ async function waitForBuild(
171255
let attempts = 0;
172256

173257
while (attempts < maxAttempts) {
174-
const response = await debugFetch(`${apiUrl}${API_ENDPOINTS.getBuild}/${buildId}`, {
175-
headers: { Authorization: `Bearer ${apiKey}` },
176-
});
258+
const response = await fetchWithRetry(
259+
() =>
260+
debugFetch(`${apiUrl}${API_ENDPOINTS.getBuild}/${buildId}`, {
261+
headers: { Authorization: `Bearer ${apiKey}` },
262+
}),
263+
{ operation: 'Build status check', spinner, maxRetries: 3 }
264+
);
177265

178266
if (!response.ok) {
179267
throw new Error(`Failed to get build status: ${response.statusText}`);
@@ -215,9 +303,13 @@ async function waitForDeployment(
215303
let attempts = 0;
216304

217305
while (attempts < maxAttempts) {
218-
const response = await debugFetch(`${apiUrl}${API_ENDPOINTS.getDeployment}/${deploymentId}`, {
219-
headers: { Authorization: `Bearer ${apiKey}` },
220-
});
306+
const response = await fetchWithRetry(
307+
() =>
308+
debugFetch(`${apiUrl}${API_ENDPOINTS.getDeployment}/${deploymentId}`, {
309+
headers: { Authorization: `Bearer ${apiKey}` },
310+
}),
311+
{ operation: 'Deployment status check', spinner, maxRetries: 3 }
312+
);
221313

222314
if (!response.ok) {
223315
throw new Error(`Failed to get deployment status: ${response.statusText}`);

0 commit comments

Comments
 (0)