Files
OSIT-AE-App-Svelte/src/lib/ae_api/api_get_object.ts

490 lines
19 KiB
TypeScript

import { browser } from '$app/environment';
import { ae_auth_error } from '$lib/stores/ae_stores';
import type { key_val } from '$lib/stores/ae_stores';
export let temp_get_blob_percent_completed = 0;
export const get_blob_percent_completed = temp_get_blob_percent_completed;
export const temp_get_object_percent_completed = 0;
export const get_object_percent_completed = temp_get_object_percent_completed;
export const get_object = async function get_object({
api_cfg = null,
endpoint = '',
headers = {},
params = {},
data = {},
timeout = 20000,
return_meta = false,
return_blob = false,
filename = '',
auto_download = false,
as_list = false, // Is this still really needed?
// The task_id value should be a random string that is unique to the task. This is used to identify the task in the message event.
task_id = crypto.randomUUID(),
log_lvl = 0,
retry_count = 5 // Number of retry attempts
}: {
api_cfg: any;
endpoint: string;
headers?: any;
params?: any;
data?: any;
timeout?: number;
return_meta?: boolean;
return_blob?: boolean;
filename?: null | string;
auto_download?: boolean;
as_list?: boolean;
task_id?: string;
log_lvl?: number;
retry_count?: number;
}) {
if (log_lvl) {
console.log(
`*** get_object() *** Endpoint: ${endpoint} AE Task ID: ${task_id}`
);
console.log('Params:', params);
if (log_lvl > 1) {
console.log('Data:', data);
}
}
if (!api_cfg) {
console.log('No API Config was provided. Returning false.');
return false;
}
// FAIL FAST: Check if we are explicitly offline to avoid long browser timeouts
if (typeof navigator !== 'undefined' && !navigator.onLine) {
if (log_lvl)
console.log(
'get_object: Browser is offline. Failing fast to allow cache fallback.'
);
return false;
}
if (!filename && params.filename) {
filename = params.filename;
}
const url = new URL(endpoint, api_cfg['base_url']);
Object.keys(params).forEach((key) =>
url.searchParams.append(key, params[key])
);
// Clean and merge headers without mutating the original api_cfg
const headers_cleaned: key_val = {};
const merged_headers = { ...api_cfg['headers'], ...headers };
// Auto-promote account_id from api_cfg to header if missing
let account_id = merged_headers['x-account-id'] || api_cfg['account_id'];
// IMMEDIATE ACCOUNT ID SCAVENGING: Read from localStorage to avoid race conditions
if (!account_id && typeof localStorage !== 'undefined') {
try {
const ae_loc_raw = localStorage.getItem('ae_loc');
if (ae_loc_raw) {
const ae_loc_json = JSON.parse(ae_loc_raw);
if (ae_loc_json.account_id) {
account_id = ae_loc_json.account_id;
}
}
} catch (e) {
// Silently fail on storage read
}
}
if (account_id) {
merged_headers['x-account-id'] = account_id;
}
// Handle "Bootstrap Paradox" for unauthenticated requests
const bypass_val =
merged_headers['x-no-account-id'] || merged_headers['x_no_account_id'];
const is_valid_bypass =
bypass_val === 'bypass' ||
bypass_val === 'Nothing to See Here' ||
bypass_val === 'direct-download';
if (is_valid_bypass) {
if (log_lvl > 1)
console.log(
'api_get_object: Valid bypass detected. Stripping account ID context.'
);
delete merged_headers['x-account-id'];
delete merged_headers['x_account_id'];
} else {
// If it's a placeholder (like "No_Account_ID_Here"), just remove the bypass header
// but DO NOT strip the valid Account ID.
delete merged_headers['x-no-account-id'];
delete merged_headers['x_no_account_id'];
}
// Standardize all headers to kebab-case and ensure string values
for (const prop in merged_headers) {
const prop_cleaned = prop.replaceAll('_', '-');
let value = merged_headers[prop];
if (value === null || value === undefined) continue;
if (typeof value !== 'string') {
value = JSON.stringify(value);
}
headers_cleaned[prop_cleaned] = value;
}
// Auto-inject Authorization header if JWT is present but header is missing
let jwt =
headers_cleaned['jwt'] ||
headers_cleaned['JWT'] ||
api_cfg['jwt'] ||
api_cfg['headers']?.['jwt'] ||
api_cfg['headers']?.['JWT'];
// Final Fallback: Direct check of primary ae_loc key
if (!jwt && typeof localStorage !== 'undefined') {
try {
const raw = localStorage.getItem('ae_loc');
if (raw) {
const json = JSON.parse(raw);
if (json.jwt) jwt = json.jwt;
}
} catch (e) {
// Silently fail on storage read
}
}
if (
jwt &&
!headers_cleaned['Authorization'] &&
!headers_cleaned['authorization']
) {
headers_cleaned['Authorization'] = `Bearer ${jwt}`;
}
if (log_lvl > 1) {
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,
// Be explicit about CORS behavior and redirect handling to avoid
// environment-dependent defaults that can cause opaque failures.
mode: 'cors',
credentials: 'omit',
redirect: 'follow',
cache: 'no-store'
};
if (log_lvl > 1) {
console.log('Fetch options:', fetchOptions);
}
let fetch_method: any = fetch;
if (api_cfg.fetch) {
if (log_lvl > 1) {
console.log('Using custom fetch function from api_cfg!!!');
}
fetch_method = api_cfg.fetch;
}
for (let attempt = 1; attempt <= retry_count; attempt++) {
// FAIL FAST: Check if we are explicitly offline to avoid long browser timeouts
if (typeof navigator !== 'undefined' && !navigator.onLine) {
if (log_lvl)
console.log(
`get_object: Browser is offline (attempt ${attempt}). Failing fast to allow cache fallback.`
);
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();
// 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(() => {
did_timeout_abort = true;
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, signal: controller.signal }
).catch(function (error: any) {
// SILENCE NOISE: Aborted requests (common in SWR/Background loads) shouldn't spam logs
if (
error.name === 'AbortError' ||
error.message?.includes('aborted') ||
error.name === 'TypeError'
) {
if (log_lvl > 1) {
console.log(
'API GET: Request was aborted or terminated by browser. This is expected during navigation.',
error
);
}
return error; // Return error to be handled below
}
console.log(
'API GET Object *fetch* request failed in an unexpected way.',
error
);
return error;
});
clearTimeout(timeoutId);
// Check if we should stop due to abort or network failure.
if (
response instanceof Error ||
(response &&
(response.name === 'TypeError' ||
response.name === 'AbortError'))
) {
// AbortError can be either timeout or intentional abort.
// Retry only helper-owned timeout aborts; fail fast on caller abort.
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,
// 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) {
if (log_lvl > 1) {
console.log(
'API GET Object: Something went wrong with *fetch* request. Returning false? Throwing an error!'
);
}
throw new Error(
`HTTP fetch request was aborted or failed in an unexpected way! URL = ${url.toString()}`
); // This will allow it to retry
// return false; // This will stop the retries
}
if (log_lvl) {
console.log(
`Response: status=${response.status} statusText=${response.statusText} url=${response.url} attempt=${attempt}`
);
try {
console.log(
'Response headers:',
Object.fromEntries(response.headers.entries())
);
} catch (e) {
/* ignore header read errors */
}
}
if (log_lvl > 1) {
console.log('Response:', response);
}
if (!response.ok) {
if (response.status === 404) {
if (log_lvl) {
console.log(
'The response was a 404 not found "error". Returning null.'
);
}
return null;
}
// FAIL FAST (Section 2D): Do not retry on Auth or Client errors (400, 401, 403, 422)
if (
response.status === 400 ||
response.status === 401 ||
response.status === 403 ||
response.status === 422
) {
if (log_lvl)
console.error(
`API Client Failure (${response.status}). Failing fast.`
);
if (response.status === 401 || response.status === 403) {
console.warn(
`AUTH DIAGNOSTICS: Headers sent for ${endpoint}:`,
{
has_auth: !!headers_cleaned['Authorization'],
has_api_key:
!!headers_cleaned['x-aether-api-key'],
has_account_id:
!!headers_cleaned['x-account-id'],
jwt_preview: jwt
? `${jwt.slice(0, 8)}...`
: 'MISSING'
}
);
// Signal the root layout to show the session-expired banner.
if (browser)
ae_auth_error.set({
type: 'expired',
ts: Date.now()
});
}
// Structured Error Handling (V3): Attempt to get rich error metadata
let error_json: any = null;
try {
error_json = await response.json();
} catch (e) {
// Not JSON
}
if (log_lvl)
console.log(
'The response was not ok. Structured Error Check:',
error_json
);
if (error_json?.meta?.details) {
return error_json;
}
// Fallback for standard FastAPI "detail" errors
if (error_json?.detail) {
return {
meta: {
success: false,
status_code: response.status,
details: {
category: 'validation',
message:
typeof error_json.detail === 'string'
? error_json.detail
: JSON.stringify(error_json.detail),
raw: error_json.detail
}
}
};
}
return false;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!return_blob) {
const json = await response.json();
if (log_lvl > 1) {
console.log('Response JSON:', json);
}
if (!Array.isArray(json.data) && as_list) {
return [json.data];
}
return json.data || json;
} else {
const reader = response.body?.getReader();
const contentLength = +response.headers.get('Content-Length')!;
let receivedLength = 0;
const chunks = [];
while (true) {
const { done, value } = await reader!.read();
if (done) break;
chunks.push(value);
receivedLength += value.length;
const percent_completed = Math.round(
(receivedLength * 100) / contentLength
);
if (log_lvl > 1) {
console.log(
'GET Blob Progress:',
percent_completed,
'Total:',
contentLength,
'Loaded:',
receivedLength,
'Percent Completed',
percent_completed
);
}
temp_get_blob_percent_completed = percent_completed;
try {
if (typeof window !== 'undefined') {
window.postMessage(
{
type: 'api_download_blob',
status: 'downloading',
task_id: task_id,
endpoint: endpoint,
filename: filename,
size_total: contentLength,
size_loaded: receivedLength,
percent_completed: percent_completed
},
'*'
);
}
} catch (e) {
console.error('Error posting message:', e);
}
}
const blob = new Blob(chunks);
if (auto_download) {
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.setAttribute('download', filename || 'download');
document.body.appendChild(link);
link.click();
link.remove();
return true;
} else {
return blob;
}
}
} 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
);
if (attempt === retry_count) {
console.log('Max retry attempts reached. Returning false.');
return false;
}
// 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));
}
}
};