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. After a10accfaa, .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:
Scott Idem
2026-05-21 13:44:12 -04:00
parent e6db2b4d6a
commit 689bb326cb
2 changed files with 60 additions and 33 deletions

View File

@@ -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<void>((resolve) => setTimeout(resolve, delay_ms));
}
}
};

View File

@@ -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<void>((resolve) => setTimeout(resolve, delay_ms));
}
}
};