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

@@ -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));
}
}
};