fix(api): restore network-error retry and add backoff in get/post_object
The Jan 2026 "offline-first fast-paths" commit (a10accfaa) inadvertently broke retries for transient network failures (ERR_NETWORK_CHANGED, WiFi roam events, etc.). The original code's .catch() returned undefined, which fell through to the `if (!response) throw` path and correctly entered the retry loop. Aftera10accfaa, .catch() returned the error as a value, and the subsequent `instanceof Error` check returned false immediately — bypassing all retries for the most common failure mode in hotel/conference environments. Changes: - TypeError now throws into the retry loop instead of returning false - AbortError still returns false immediately (intentional cancel, no retry) - Per-attempt AbortController: moved inside the loop in both files so each retry gets its own independent timeout (previously GET retries had no timeout at all after the first attempt's clearTimeout ran) - clearTimeout() added to catch block so timer is always cancelled on error - Exponential backoff added: 2s→4s→6s→8s (capped) between attempts; rapid retries on a flaky network accomplish nothing without a delay - Default timeout lowered: 90s → 20s (generous for search/GET but avoids the 90s worst-case hang that amplified ERR_NETWORK_CHANGED exposure) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ export const get_object = async function get_object({
|
|||||||
headers = {},
|
headers = {},
|
||||||
params = {},
|
params = {},
|
||||||
data = {},
|
data = {},
|
||||||
timeout = 90000,
|
timeout = 20000,
|
||||||
return_meta = false,
|
return_meta = false,
|
||||||
return_blob = false,
|
return_blob = false,
|
||||||
filename = '',
|
filename = '',
|
||||||
@@ -73,9 +73,6 @@ export const get_object = async function get_object({
|
|||||||
url.searchParams.append(key, params[key])
|
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
|
// Clean and merge headers without mutating the original api_cfg
|
||||||
const headers_cleaned: key_val = {};
|
const headers_cleaned: key_val = {};
|
||||||
const merged_headers = { ...api_cfg['headers'], ...headers };
|
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);
|
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 = {
|
const fetchOptions: RequestInit = {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: headers_cleaned,
|
headers: headers_cleaned,
|
||||||
signal: controller.signal,
|
|
||||||
// Be explicit about CORS behavior and redirect handling to avoid
|
// Be explicit about CORS behavior and redirect handling to avoid
|
||||||
// environment-dependent defaults that can cause opaque failures.
|
// environment-dependent defaults that can cause opaque failures.
|
||||||
mode: 'cors',
|
mode: 'cors',
|
||||||
@@ -203,10 +201,19 @@ export const get_object = async function get_object({
|
|||||||
return false;
|
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 {
|
try {
|
||||||
const response = await fetch_method(
|
const response = await fetch_method(
|
||||||
url.toString(),
|
url.toString(),
|
||||||
fetchOptions
|
{ ...fetchOptions, signal: controller.signal }
|
||||||
).catch(function (error: any) {
|
).catch(function (error: any) {
|
||||||
// SILENCE NOISE: Aborted requests (common in SWR/Background loads) shouldn't spam logs
|
// SILENCE NOISE: Aborted requests (common in SWR/Background loads) shouldn't spam logs
|
||||||
if (
|
if (
|
||||||
@@ -231,21 +238,29 @@ export const get_object = async function get_object({
|
|||||||
});
|
});
|
||||||
clearTimeout(timeoutId);
|
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 (
|
if (
|
||||||
response instanceof Error ||
|
response instanceof Error ||
|
||||||
(response &&
|
(response &&
|
||||||
(response.name === 'TypeError' ||
|
(response.name === 'TypeError' ||
|
||||||
response.name === 'AbortError'))
|
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 (response.name === 'AbortError') return false;
|
||||||
|
|
||||||
if (log_lvl > 1)
|
// TypeError = transient network failure (ERR_NETWORK_CHANGED,
|
||||||
console.log(
|
// ERR_NETWORK_IO_SUSPENDED, hotel/conference WiFi blip, etc.).
|
||||||
'API GET Object: Detected NetworkError or TypeError. Failing fast.'
|
// IMPORTANT: throw here so the retry loop's catch block handles it with
|
||||||
);
|
// backoff. Returning false would bypass retries entirely.
|
||||||
return false;
|
//
|
||||||
|
// 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) {
|
if (!response) {
|
||||||
@@ -438,6 +453,8 @@ export const get_object = async function get_object({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Ensure the per-attempt timeout timer is always cancelled on failure.
|
||||||
|
clearTimeout(timeoutId);
|
||||||
console.log(
|
console.log(
|
||||||
`API GET object request *fetch* error on attempt ${attempt}:`,
|
`API GET object request *fetch* error on attempt ${attempt}:`,
|
||||||
error
|
error
|
||||||
@@ -448,10 +465,13 @@ export const get_object = async function get_object({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log retry information
|
// Backoff before retrying. Without a delay, rapid retries on a flaky
|
||||||
if (log_lvl) {
|
// connection accomplish nothing and add noise. Caps at 8s so later
|
||||||
console.log(`Retrying... (${attempt}/${retry_count})`);
|
// 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<void>((resolve) => setTimeout(resolve, delay_ms));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const post_object = async function post_object({
|
|||||||
params = {},
|
params = {},
|
||||||
data = {},
|
data = {},
|
||||||
form_data = null,
|
form_data = null,
|
||||||
timeout = 90000,
|
timeout = 20000,
|
||||||
return_meta = false,
|
return_meta = false,
|
||||||
return_blob = false,
|
return_blob = false,
|
||||||
filename = '',
|
filename = '',
|
||||||
@@ -200,13 +200,15 @@ export const post_object = async function post_object({
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= retry_count; attempt++) {
|
for (let attempt = 1; attempt <= retry_count; attempt++) {
|
||||||
try {
|
// Declared at loop scope (not inside try) so the catch block can clearTimeout.
|
||||||
const controller = new AbortController();
|
// Fresh controller per attempt — same rationale as api_get_object.ts.
|
||||||
const timeoutId = setTimeout(() => {
|
const controller = new AbortController();
|
||||||
console.error(`API POST request timed out after ${timeout}ms.`);
|
const timeoutId = setTimeout(() => {
|
||||||
controller.abort();
|
console.warn(`API POST: Request timed out after ${timeout}ms (attempt ${attempt}/${retry_count}).`);
|
||||||
}, timeout);
|
controller.abort();
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
try {
|
||||||
const fetchOptions: RequestInit = {
|
const fetchOptions: RequestInit = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: headers_cleaned,
|
headers: headers_cleaned,
|
||||||
@@ -245,19 +247,21 @@ export const post_object = async function post_object({
|
|||||||
});
|
});
|
||||||
clearTimeout(timeoutId);
|
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 (
|
if (
|
||||||
response instanceof Error ||
|
response instanceof Error ||
|
||||||
(response &&
|
(response &&
|
||||||
(response.name === 'TypeError' ||
|
(response.name === 'TypeError' ||
|
||||||
response.name === 'AbortError'))
|
response.name === 'AbortError'))
|
||||||
) {
|
) {
|
||||||
|
// AbortError = intentional cancel (timeout fired, or caller aborted).
|
||||||
|
// Never retry — the abort was deliberate.
|
||||||
if (response.name === 'AbortError') return false;
|
if (response.name === 'AbortError') return false;
|
||||||
if (log_lvl > 1)
|
|
||||||
console.log(
|
// TypeError = transient network failure. Throw into the retry loop
|
||||||
'API POST Object: Detected NetworkError or TypeError. Failing fast.'
|
// so backoff-and-retry applies. Same fix as api_get_object.ts — see
|
||||||
);
|
// comment there for the full history of why this was broken.
|
||||||
return false;
|
throw new Error(`Network error (attempt ${attempt}): ${response.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response) {
|
if (!response) {
|
||||||
@@ -411,6 +415,8 @@ export const post_object = async function post_object({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Ensure the per-attempt timeout timer is always cancelled on failure.
|
||||||
|
clearTimeout(timeoutId);
|
||||||
console.error(`API POST error on attempt ${attempt}:`, error);
|
console.error(`API POST error on attempt ${attempt}:`, error);
|
||||||
|
|
||||||
if (attempt === retry_count) {
|
if (attempt === retry_count) {
|
||||||
@@ -418,9 +424,10 @@ export const post_object = async function post_object({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (log_lvl) {
|
// Backoff before retrying — same rationale as api_get_object.ts.
|
||||||
console.log(`Retrying... (${attempt}/${retry_count})`);
|
const delay_ms = Math.min(2000 * attempt, 8000);
|
||||||
}
|
console.log(`API POST: Retrying in ${delay_ms}ms... (attempt ${attempt}/${retry_count})`);
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, delay_ms));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user