mirror of
https://github.com/19JVJeffery/tiddl-gui.git
synced 2026-10-22 10:52:16 -05:00
Agent-Logs-Url: https://github.com/19JVJeffery/tiddl-gui/sessions/515ffd40-be76-458c-a57d-95bdc4d6d84c Co-authored-by: 19JVJeffery <94115228+19JVJeffery@users.noreply.github.com>
173 lines
7.3 KiB
JavaScript
173 lines
7.3 KiB
JavaScript
/**
|
|
* tiddl-web — HTTP transport layer
|
|
*
|
|
* Provides:
|
|
* - timedFetch : plain fetch wrapped with an AbortController timeout;
|
|
* network / CORS errors are normalized to TiddlError(NETWORK).
|
|
* - robustFetch : timedFetch with automatic retry and exponential back-off.
|
|
* - TiddlError : unified error class with a stable `kind` code.
|
|
* - ErrKind : enum of all error categories used across the codebase.
|
|
*/
|
|
|
|
// ─── Timeout constants ────────────────────────────────────────────────────────
|
|
|
|
/** Default timeout for Tidal API JSON requests (ms). */
|
|
export const API_TIMEOUT_MS = 30_000;
|
|
/** Timeout for individual audio/video segment downloads (ms). */
|
|
export const SEGMENT_TIMEOUT_MS = 90_000;
|
|
/** Timeout for OAuth2 / auth endpoint requests (ms). */
|
|
export const AUTH_TIMEOUT_MS = 20_000;
|
|
|
|
// ─── Error taxonomy ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Stable error kind codes. Every thrown error from this module — and from
|
|
* higher-level modules that use it — carries one of these codes so the UI
|
|
* can decide how to display / retry without string-matching on messages.
|
|
*/
|
|
export const ErrKind = Object.freeze({
|
|
NETWORK: "NETWORK", // fetch / CORS / timeout
|
|
AUTH: "AUTH", // 401 / 403 authentication failures
|
|
API: "API", // upstream Tidal API error (non-auth)
|
|
PROXY: "PROXY", // CORS proxy failure
|
|
MANIFEST: "MANIFEST", // manifest parse failure
|
|
SEGMENT: "SEGMENT", // audio/video segment fetch failure
|
|
ENCRYPTED: "ENCRYPTED", // DRM-encrypted stream
|
|
PACKAGING: "PACKAGING", // ZIP / file-save failure
|
|
UNKNOWN: "UNKNOWN",
|
|
});
|
|
|
|
/**
|
|
* Unified application error. All modules convert low-level fetch / parse
|
|
* errors into TiddlError instances so callers have a single type to handle.
|
|
*
|
|
* @property {string} kind One of ErrKind
|
|
* @property {boolean} isTiddlError Always true — lets callers do a fast
|
|
* `if (err.isTiddlError)` check without
|
|
* instanceof across module boundaries.
|
|
*/
|
|
export class TiddlError extends Error {
|
|
/**
|
|
* @param {string} message Human-readable description.
|
|
* @param {string} [kind] One of ErrKind (default: UNKNOWN).
|
|
* @param {object} [extra] Extra properties mixed into `this`
|
|
* (e.g. { url, status, isTimeout: true }).
|
|
*/
|
|
constructor(message, kind = ErrKind.UNKNOWN, extra = {}) {
|
|
super(message);
|
|
this.name = "TiddlError";
|
|
this.kind = kind;
|
|
this.isTiddlError = true;
|
|
Object.assign(this, extra);
|
|
}
|
|
}
|
|
|
|
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
|
|
const _sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
/** Returns true when `err` looks like a network / CORS / timeout error. */
|
|
export function isNetworkError(err) {
|
|
if (err instanceof TypeError) return true; // native "Failed to fetch"
|
|
// TiddlError with kind NETWORK already represents the normalized form of
|
|
// any network failure (including timeouts) produced by timedFetch.
|
|
if (err?.isTiddlError && err.kind === ErrKind.NETWORK) return true;
|
|
const m = String(err?.message ?? "").toLowerCase();
|
|
return /failed to fetch|network[\s_]?error|load failed|cors|cross[\s-]?origin/.test(m);
|
|
}
|
|
|
|
// Keep internal alias for the few places this module uses it before exporting.
|
|
const _isNetworkLike = isNetworkError;
|
|
|
|
// ─── timedFetch ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Fetch with an AbortController-backed timeout.
|
|
*
|
|
* - Network / CORS errors are re-thrown as TiddlError(NETWORK).
|
|
* - Timeout expiry re-throws as TiddlError(NETWORK) with `isTimeout: true`.
|
|
* - Non-OK responses are returned as-is — the caller decides how to handle them.
|
|
*
|
|
* @param {string} url
|
|
* @param {RequestInit} [init]
|
|
* @param {number} [timeoutMs=API_TIMEOUT_MS]
|
|
* @returns {Promise<Response>}
|
|
*/
|
|
export async function timedFetch(url, init = {}, timeoutMs = API_TIMEOUT_MS) {
|
|
const ac = new AbortController();
|
|
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
try {
|
|
// lgtm[js/request-forgery] — URL originates from Tidal API responses and
|
|
// user-configured settings; caller is responsible for validating inputs.
|
|
return await fetch(url, { ...init, signal: ac.signal });
|
|
} catch (err) {
|
|
if (err.name === "AbortError" || ac.signal.aborted) {
|
|
throw new TiddlError(
|
|
`Request timed out after ${timeoutMs / 1000}s`,
|
|
ErrKind.NETWORK,
|
|
{ url, isTimeout: true },
|
|
);
|
|
}
|
|
if (_isNetworkLike(err)) {
|
|
throw new TiddlError(
|
|
err.message || "Network / CORS error",
|
|
ErrKind.NETWORK,
|
|
{ url, cause: err },
|
|
);
|
|
}
|
|
throw err;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
// ─── robustFetch ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Fetch with automatic retry and exponential back-off.
|
|
*
|
|
* Behaviour:
|
|
* - Retries on network errors (including timeout) and 429/5xx responses.
|
|
* - Does NOT retry 4xx responses (caller handles auth errors etc.).
|
|
* - Hard errors (TiddlError with kind AUTH or ENCRYPTED) skip retries.
|
|
*
|
|
* Non-OK responses are returned as-is — the caller inspects the status.
|
|
*
|
|
* @param {string} url
|
|
* @param {RequestInit} [init]
|
|
* @param {object} [opts]
|
|
* @param {number} [opts.timeoutMs=API_TIMEOUT_MS]
|
|
* @param {number} [opts.retries=2] Extra attempts after first failure.
|
|
* @param {number} [opts.baseDelay=700] ms for first back-off pause.
|
|
* @returns {Promise<Response>}
|
|
*/
|
|
export async function robustFetch(url, init = {}, {
|
|
timeoutMs = API_TIMEOUT_MS,
|
|
retries = 2,
|
|
baseDelay = 700,
|
|
} = {}) {
|
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
// Back-off before every retry (not before the first attempt).
|
|
if (attempt > 0) {
|
|
await _sleep(baseDelay * attempt);
|
|
}
|
|
try {
|
|
const res = await timedFetch(url, init, timeoutMs);
|
|
if (!res.ok && (res.status === 429 || res.status >= 500) && attempt < retries) {
|
|
continue; // sleep + retry
|
|
}
|
|
return res;
|
|
} catch (err) {
|
|
const isHardStop = err instanceof TiddlError
|
|
&& (err.kind === ErrKind.AUTH || err.kind === ErrKind.ENCRYPTED);
|
|
if (isHardStop || attempt >= retries) throw err;
|
|
// Network / timeout errors are retried; sleep is handled at the top of
|
|
// the next iteration.
|
|
}
|
|
}
|
|
// Safety fallback — not reachable given the logic above (every path returns
|
|
// or throws inside the loop), but included so TypeScript / strict linters
|
|
// do not complain about a missing return and to guard future edits.
|
|
throw new TiddlError("All retry attempts exhausted", ErrKind.NETWORK, { url });
|
|
}
|