Skip to content

Commit dc915c4

Browse files
committed
retry on timeouts
1 parent 0147e44 commit dc915c4

1 file changed

Lines changed: 75 additions & 3 deletions

File tree

internal/server/static/index.html

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2171,11 +2171,74 @@ <h3>Why calculate PR costs?</h3>
21712171
}
21722172
});
21732173

2174-
async function handleStreamingRequest(endpoint, request, resultDiv) {
2174+
async function handleStreamingRequest(endpoint, request, resultDiv, maxRetries = 8) {
2175+
// Wrap streaming with automatic retry logic using exponential backoff with jitter
2176+
// Max retries = 8 allows for backoff up to 120s: 1s, 2s, 4s, 8s, 16s, 32s, 64s, 120s
2177+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
2178+
try {
2179+
await attemptStreamingRequest(endpoint, request, resultDiv, attempt, maxRetries);
2180+
if (attempt > 1) {
2181+
console.log(`Stream request succeeded after ${attempt} attempts`);
2182+
}
2183+
return; // Success, exit
2184+
} catch (error) {
2185+
// Check if error is retryable (network/timeout errors)
2186+
const isRetryable = error.message.includes('Failed to fetch') ||
2187+
error.message.includes('network') ||
2188+
error.message.includes('timeout') ||
2189+
error.message.includes('aborted') ||
2190+
error.name === 'TypeError' ||
2191+
error.name === 'AbortError';
2192+
2193+
if (isRetryable && attempt < maxRetries) {
2194+
// Exponential backoff with jitter: 1s, 2s, 4s, 8s, 16s, 32s, 64s, up to 120s
2195+
const baseDelay = Math.min(1000 * Math.pow(2, attempt - 1), 120000);
2196+
// Add jitter: random value between 0% and 25% of base delay
2197+
const jitter = Math.random() * 0.25 * baseDelay;
2198+
const delay = Math.floor(baseDelay + jitter);
2199+
2200+
console.log(`Stream connection lost (attempt ${attempt}/${maxRetries}): ${error.message}`);
2201+
console.log(`Retrying in ${delay}ms with exponential backoff + jitter`);
2202+
2203+
// Show retry message to user
2204+
const submitBtn = document.querySelector('button[type="submit"]');
2205+
submitBtn.textContent = `Connection lost, retrying in ${Math.ceil(delay/1000)}s...`;
2206+
2207+
await new Promise(resolve => setTimeout(resolve, delay));
2208+
continue;
2209+
}
2210+
2211+
// Non-retryable error or max retries exceeded
2212+
console.error(`Stream request failed permanently: ${error.message}`);
2213+
throw error;
2214+
}
2215+
}
2216+
}
2217+
2218+
async function attemptStreamingRequest(endpoint, request, resultDiv, attempt, maxRetries) {
21752219
// EventSource doesn't support POST, so we need a different approach
21762220
// We'll use fetch to initiate, but handle it as a proper SSE stream
21772221
return new Promise((resolve, reject) => {
21782222
let progressContainer;
2223+
let lastActivityTime = Date.now();
2224+
let timeoutId;
2225+
let reader; // Declare reader in outer scope to prevent race condition
2226+
2227+
// Set up activity timeout (10 seconds of no data = connection lost)
2228+
// Server sends updates every ~5s, so 10s allows for network latency
2229+
const resetTimeout = () => {
2230+
if (timeoutId) clearTimeout(timeoutId);
2231+
lastActivityTime = Date.now();
2232+
timeoutId = setTimeout(() => {
2233+
const elapsed = Date.now() - lastActivityTime;
2234+
if (elapsed >= 10000) {
2235+
if (reader) {
2236+
reader.cancel().catch(() => {}); // Ignore cancel errors
2237+
}
2238+
reject(new Error('Stream timeout: no data received for 10 seconds'));
2239+
}
2240+
}, 10000);
2241+
};
21792242

21802243
// Make the POST request with fetch
21812244
fetch(endpoint, {
@@ -2186,23 +2249,29 @@ <h3>Why calculate PR costs?</h3>
21862249
body: JSON.stringify(request)
21872250
}).then(response => {
21882251
if (!response.ok) {
2252+
if (timeoutId) clearTimeout(timeoutId);
21892253
return response.text().then(error => {
21902254
throw new Error(error || `HTTP ${response.status}`);
21912255
});
21922256
}
21932257

21942258
// Use the proper streaming API with ReadableStream
2195-
const reader = response.body.getReader();
2259+
reader = response.body.getReader();
21962260
const decoder = new TextDecoder();
21972261
let buffer = '';
21982262

2263+
resetTimeout(); // Start timeout monitoring
2264+
21992265
function read() {
22002266
reader.read().then(({done, value}) => {
22012267
if (done) {
2268+
if (timeoutId) clearTimeout(timeoutId);
22022269
resolve();
22032270
return;
22042271
}
22052272

2273+
resetTimeout(); // Reset timeout on data received
2274+
22062275
buffer += decoder.decode(value, {stream: true});
22072276
const lines = buffer.split('\n');
22082277
buffer = lines.pop() || '';
@@ -2215,7 +2284,8 @@ <h3>Why calculate PR costs?</h3>
22152284

22162285
if (data.type === 'error' && !data.pr) {
22172286
// Global error
2218-
reader.cancel();
2287+
if (timeoutId) clearTimeout(timeoutId);
2288+
reader.cancel().catch(() => {}); // Ignore cancel errors
22192289
reject(new Error(data.error));
22202290
return;
22212291
}
@@ -2365,13 +2435,15 @@ <h3>Why calculate PR costs?</h3>
23652435
// Continue reading
23662436
read();
23672437
}).catch(error => {
2438+
if (timeoutId) clearTimeout(timeoutId);
23682439
reject(error);
23692440
});
23702441
}
23712442

23722443
// Start reading
23732444
read();
23742445
}).catch(error => {
2446+
if (timeoutId) clearTimeout(timeoutId);
23752447
reject(error);
23762448
});
23772449
});

0 commit comments

Comments
 (0)