api: separate timeout abort retries from intentional aborts
This commit is contained in:
@@ -156,6 +156,45 @@ below. The TTL + `verify_in_flight` guards are the current mitigation.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### [API] GET/POST retry hardening — differentiate timeout aborts vs intentional aborts
|
||||||
|
**Status:** 🚧 Planned follow-up (2026-05-21)
|
||||||
|
|
||||||
|
Recent API helper fixes restored retry/backoff for transient network `TypeError` failures.
|
||||||
|
Current remaining gap: timeout-triggered aborts are treated the same as intentional/user
|
||||||
|
aborts, so retries are skipped in both `api_get_object.ts` and `api_post_object.ts`.
|
||||||
|
|
||||||
|
**Decision (for now):** Keep the global default timeout at **20s**.
|
||||||
|
|
||||||
|
**What needs to be implemented:**
|
||||||
|
- Separate abort reasons in GET/POST helpers:
|
||||||
|
- **Intentional abort** (navigation/unmount/caller cancel): fail fast, no retry
|
||||||
|
- **Timeout abort** (helper's own timer): eligible for retry/backoff (same class as transient network)
|
||||||
|
- Add explicit timeout classification in code (not just `AbortError` name check), so the retry
|
||||||
|
loop can make a deterministic decision.
|
||||||
|
- Keep existing capped backoff behavior (`2s -> 4s -> 6s -> 8s`) for retryable timeout/network failures.
|
||||||
|
|
||||||
|
**Timeout policy improvement (class-based):**
|
||||||
|
- Keep **20s default** as baseline.
|
||||||
|
- Add request classes with explicit timeout selection at callsites/wrappers (not random per-page values):
|
||||||
|
- fast CRUD/read/search: ~20s baseline
|
||||||
|
- medium actions: higher bounded timeout
|
||||||
|
- heavy actions (uploads, exports, ffmpeg/video clip): explicit long timeout already required
|
||||||
|
- Centralize the class mapping so timeout intent is clear and audit-friendly.
|
||||||
|
|
||||||
|
**Primary files:**
|
||||||
|
- `src/lib/ae_api/api_get_object.ts`
|
||||||
|
- `src/lib/ae_api/api_post_object.ts`
|
||||||
|
- Wrapper callsites in `src/lib/ae_api/` and legacy bridge points in `src/lib/api/api.ts`
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- Timeout-aborted requests retry according to retry_count/backoff policy.
|
||||||
|
- User/navigation aborts still fail fast with no retry.
|
||||||
|
- No regression on 400/401/403/422 fail-fast handling.
|
||||||
|
- Existing long-running flows that already set explicit timeouts (uploads/video tools/exports)
|
||||||
|
continue to function without behavior regressions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### [Launcher/VLC] Linux playback — fullscreen + pause-on-end not working
|
### [Launcher/VLC] Linux playback — fullscreen + pause-on-end not working
|
||||||
**Status:** Mac ✅ working perfectly; Linux 🚧 deferred for later investigation
|
**Status:** Mac ✅ working perfectly; Linux 🚧 deferred for later investigation
|
||||||
**Date discovered:** 2026-05-20
|
**Date discovered:** 2026-05-20
|
||||||
|
|||||||
@@ -205,7 +205,12 @@ export const get_object = async function get_object({
|
|||||||
// independent timeout. Sharing a single controller across retries leaves
|
// independent timeout. Sharing a single controller across retries leaves
|
||||||
// retries unprotected once the first attempt's clearTimeout() runs.
|
// retries unprotected once the first attempt's clearTimeout() runs.
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
// Track whether THIS helper's timeout fired. AbortError alone is ambiguous:
|
||||||
|
// it can mean timeout OR intentional caller abort (navigation/unmount).
|
||||||
|
// We only retry timeout-aborts; intentional aborts should fail fast.
|
||||||
|
let did_timeout_abort = false;
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
|
did_timeout_abort = true;
|
||||||
console.warn(`API GET: Request timed out after ${timeout}ms (attempt ${attempt}/${retry_count}).`);
|
console.warn(`API GET: Request timed out after ${timeout}ms (attempt ${attempt}/${retry_count}).`);
|
||||||
controller.abort();
|
controller.abort();
|
||||||
}, timeout);
|
}, timeout);
|
||||||
@@ -245,9 +250,16 @@ export const get_object = async function get_object({
|
|||||||
(response.name === 'TypeError' ||
|
(response.name === 'TypeError' ||
|
||||||
response.name === 'AbortError'))
|
response.name === 'AbortError'))
|
||||||
) {
|
) {
|
||||||
// AbortError = intentional cancel: our own timeout fired, or the caller
|
// AbortError can be either timeout or intentional abort.
|
||||||
// aborted (e.g. component unmounted, user navigated away). Never retry.
|
// Retry only helper-owned timeout aborts; fail fast on caller abort.
|
||||||
if (response.name === 'AbortError') return false;
|
if (response.name === 'AbortError') {
|
||||||
|
if (did_timeout_abort) {
|
||||||
|
throw new Error(
|
||||||
|
`Timeout abort (attempt ${attempt}/${retry_count}) after ${timeout}ms`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// TypeError = transient network failure (ERR_NETWORK_CHANGED,
|
// TypeError = transient network failure (ERR_NETWORK_CHANGED,
|
||||||
// ERR_NETWORK_IO_SUSPENDED, hotel/conference WiFi blip, etc.).
|
// ERR_NETWORK_IO_SUSPENDED, hotel/conference WiFi blip, etc.).
|
||||||
|
|||||||
@@ -203,7 +203,11 @@ export const post_object = async function post_object({
|
|||||||
// Declared at loop scope (not inside try) so the catch block can clearTimeout.
|
// Declared at loop scope (not inside try) so the catch block can clearTimeout.
|
||||||
// Fresh controller per attempt — same rationale as api_get_object.ts.
|
// Fresh controller per attempt — same rationale as api_get_object.ts.
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
// AbortError is not specific enough by itself. Distinguish timeout-aborts
|
||||||
|
// (retryable transient class) from intentional caller aborts (fail-fast).
|
||||||
|
let did_timeout_abort = false;
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
|
did_timeout_abort = true;
|
||||||
console.warn(`API POST: Request timed out after ${timeout}ms (attempt ${attempt}/${retry_count}).`);
|
console.warn(`API POST: Request timed out after ${timeout}ms (attempt ${attempt}/${retry_count}).`);
|
||||||
controller.abort();
|
controller.abort();
|
||||||
}, timeout);
|
}, timeout);
|
||||||
@@ -254,9 +258,16 @@ export const post_object = async function post_object({
|
|||||||
(response.name === 'TypeError' ||
|
(response.name === 'TypeError' ||
|
||||||
response.name === 'AbortError'))
|
response.name === 'AbortError'))
|
||||||
) {
|
) {
|
||||||
// AbortError = intentional cancel (timeout fired, or caller aborted).
|
// Retry timeout-aborts from this helper; do not retry caller aborts
|
||||||
// Never retry — the abort was deliberate.
|
// (route change/unmount/manual cancellation).
|
||||||
if (response.name === 'AbortError') return false;
|
if (response.name === 'AbortError') {
|
||||||
|
if (did_timeout_abort) {
|
||||||
|
throw new Error(
|
||||||
|
`Timeout abort (attempt ${attempt}/${retry_count}) after ${timeout}ms`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// TypeError = transient network failure. Throw into the retry loop
|
// TypeError = transient network failure. Throw into the retry loop
|
||||||
// so backoff-and-retry applies. Same fix as api_get_object.ts — see
|
// so backoff-and-retry applies. Same fix as api_get_object.ts — see
|
||||||
|
|||||||
Reference in New Issue
Block a user