diff --git a/src/lib/ae_api/api_get_object.ts b/src/lib/ae_api/api_get_object.ts index 15dbe3b0..748f88bf 100644 --- a/src/lib/ae_api/api_get_object.ts +++ b/src/lib/ae_api/api_get_object.ts @@ -14,7 +14,7 @@ export const get_object = async function get_object({ headers = {}, params = {}, data = {}, - timeout = 90000, + timeout = 20000, return_meta = false, return_blob = false, filename = '', @@ -73,9 +73,6 @@ export const get_object = async function get_object({ url.searchParams.append(key, params[key]) ); - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - // Clean and merge headers without mutating the original api_cfg const headers_cleaned: key_val = {}; const merged_headers = { ...api_cfg['headers'], ...headers }; @@ -169,10 +166,11 @@ export const get_object = async function get_object({ console.log('Final cleaned headers:', headers_cleaned); } + // signal is injected per-attempt inside the retry loop so each retry gets + // a fresh AbortController with its own independent timeout. const fetchOptions: RequestInit = { method: 'GET', headers: headers_cleaned, - signal: controller.signal, // Be explicit about CORS behavior and redirect handling to avoid // environment-dependent defaults that can cause opaque failures. mode: 'cors', @@ -203,10 +201,19 @@ export const get_object = async function get_object({ return false; } + // Fresh AbortController per attempt — ensures each retry has its own + // independent timeout. Sharing a single controller across retries leaves + // retries unprotected once the first attempt's clearTimeout() runs. + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + console.warn(`API GET: Request timed out after ${timeout}ms (attempt ${attempt}/${retry_count}).`); + controller.abort(); + }, timeout); + try { const response = await fetch_method( url.toString(), - fetchOptions + { ...fetchOptions, signal: controller.signal } ).catch(function (error: any) { // SILENCE NOISE: Aborted requests (common in SWR/Background loads) shouldn't spam logs if ( @@ -231,21 +238,29 @@ export const get_object = async function get_object({ }); clearTimeout(timeoutId); - // Check if we should stop due to abort or network failure + // Check if we should stop due to abort or network failure. if ( response instanceof Error || (response && (response.name === 'TypeError' || response.name === 'AbortError')) ) { - // If it was an explicit abort, definitely stop + // AbortError = intentional cancel: our own timeout fired, or the caller + // aborted (e.g. component unmounted, user navigated away). Never retry. if (response.name === 'AbortError') return false; - if (log_lvl > 1) - console.log( - 'API GET Object: Detected NetworkError or TypeError. Failing fast.' - ); - return false; + // TypeError = transient network failure (ERR_NETWORK_CHANGED, + // ERR_NETWORK_IO_SUSPENDED, hotel/conference WiFi blip, etc.). + // IMPORTANT: throw here so the retry loop's catch block handles it with + // backoff. Returning false would bypass retries entirely. + // + // WHY THIS WAS BROKEN: The Jan 2026 "offline-first fast-paths" commit + // (a10accfaa) changed .catch() to return the error as a value instead of + // not returning (undefined). The undefined path fell through to the + // `if (!response)` throw which DID retry. The explicit `return error` + + // this `return false` block silently killed the retry for the most common + // failure mode on conference/hotel WiFi. + throw new Error(`Network error (attempt ${attempt}): ${response.message}`); } if (!response) { @@ -438,6 +453,8 @@ export const get_object = async function get_object({ } } } catch (error) { + // Ensure the per-attempt timeout timer is always cancelled on failure. + clearTimeout(timeoutId); console.log( `API GET object request *fetch* error on attempt ${attempt}:`, error @@ -448,10 +465,13 @@ export const get_object = async function get_object({ return false; } - // Log retry information - if (log_lvl) { - console.log(`Retrying... (${attempt}/${retry_count})`); - } + // Backoff before retrying. Without a delay, rapid retries on a flaky + // connection accomplish nothing and add noise. Caps at 8s so later + // attempts don't wait excessively. Gives the network time to recover + // (ERR_NETWORK_CHANGED is typically a sub-second WiFi roam event). + const delay_ms = Math.min(2000 * attempt, 8000); + console.log(`API GET: Retrying in ${delay_ms}ms... (attempt ${attempt}/${retry_count})`); + await new Promise((resolve) => setTimeout(resolve, delay_ms)); } } }; diff --git a/src/lib/ae_api/api_post_object.ts b/src/lib/ae_api/api_post_object.ts index 104aeb47..cf976601 100644 --- a/src/lib/ae_api/api_post_object.ts +++ b/src/lib/ae_api/api_post_object.ts @@ -15,7 +15,7 @@ export const post_object = async function post_object({ params = {}, data = {}, form_data = null, - timeout = 90000, + timeout = 20000, return_meta = false, return_blob = false, filename = '', @@ -200,13 +200,15 @@ export const post_object = async function post_object({ } for (let attempt = 1; attempt <= retry_count; attempt++) { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => { - console.error(`API POST request timed out after ${timeout}ms.`); - controller.abort(); - }, timeout); + // Declared at loop scope (not inside try) so the catch block can clearTimeout. + // Fresh controller per attempt — same rationale as api_get_object.ts. + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + console.warn(`API POST: Request timed out after ${timeout}ms (attempt ${attempt}/${retry_count}).`); + controller.abort(); + }, timeout); + try { const fetchOptions: RequestInit = { method: 'POST', headers: headers_cleaned, @@ -245,19 +247,21 @@ export const post_object = async function post_object({ }); clearTimeout(timeoutId); - // Check if we should stop due to abort or network failure + // Check if we should stop due to abort or network failure. if ( response instanceof Error || (response && (response.name === 'TypeError' || response.name === 'AbortError')) ) { + // AbortError = intentional cancel (timeout fired, or caller aborted). + // Never retry — the abort was deliberate. if (response.name === 'AbortError') return false; - if (log_lvl > 1) - console.log( - 'API POST Object: Detected NetworkError or TypeError. Failing fast.' - ); - return false; + + // TypeError = transient network failure. Throw into the retry loop + // so backoff-and-retry applies. Same fix as api_get_object.ts — see + // comment there for the full history of why this was broken. + throw new Error(`Network error (attempt ${attempt}): ${response.message}`); } if (!response) { @@ -411,6 +415,8 @@ export const post_object = async function post_object({ } } } catch (error) { + // Ensure the per-attempt timeout timer is always cancelled on failure. + clearTimeout(timeoutId); console.error(`API POST error on attempt ${attempt}:`, error); if (attempt === retry_count) { @@ -418,9 +424,10 @@ export const post_object = async function post_object({ return false; } - if (log_lvl) { - console.log(`Retrying... (${attempt}/${retry_count})`); - } + // Backoff before retrying — same rationale as api_get_object.ts. + const delay_ms = Math.min(2000 * attempt, 8000); + console.log(`API POST: Retrying in ${delay_ms}ms... (attempt ${attempt}/${retry_count})`); + await new Promise((resolve) => setTimeout(resolve, delay_ms)); } } };