Files
OSIT-AE-App-Svelte/src/lib/ae_api/api_delete_object.ts
Scott Idem ea765d8ad2 fix(api): lower patch/delete timeout to 20s and add delete auth error banner
Two gaps found during review of the recent retry-hardening commits:

1. api_patch_object.ts and api_delete_object.ts still defaulted to 60s
   timeout while GET/POST were lowered to 20s. No callers set an explicit
   timeout, so the default was the only value used. With retry_count=5 and
   the new backoff policy, 60s per attempt = 5+ minutes worst-case wait.
   Lowered to 20s to match GET/POST and keep worst-case under ~2 minutes.

2. api_delete_object.ts had no ae_auth_error import and no session-expired
   banner on 401/403. A stale-session DELETE would silently return false
   with no user feedback. Added browser + ae_auth_error imports and the
   ae_auth_error.set() call matching the pattern in GET/POST/PATCH.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 18:11:32 -04:00

256 lines
8.7 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';
/**
* Performs a DELETE request to the Aether API.
* Refactored 2026-01-08 to use standard fetch with timeout, custom fetch injection,
* standardized kebab-case headers, and robust V3 response handling.
*/
export const delete_object = async function delete_object({
api_cfg = null,
endpoint = '',
headers = {},
params = {},
data = {},
timeout = 20000,
return_meta = false,
log_lvl = 0,
retry_count = 5
}: {
api_cfg: any;
endpoint: string;
headers?: any;
params?: any;
data?: any;
timeout?: number;
return_meta?: boolean;
log_lvl?: number;
retry_count?: number;
}) {
if (log_lvl) {
console.log(`*** delete_object() *** Endpoint: ${endpoint}`);
console.log('Params:', params);
if (log_lvl > 1) {
console.log('Data:', data);
console.log(`Base URL: ${api_cfg?.['base_url']}`);
}
}
if (!api_cfg) {
console.error('No API Config was provided. Returning false.');
return false;
}
// Construct the URL with query parameters
const url = new URL(endpoint, api_cfg['base_url']);
if (params) {
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 };
// Handle "Bootstrap Paradox" for unauthenticated requests
if (merged_headers.hasOwnProperty('x-no-account-id')) {
delete merged_headers['x-account-id'];
if (merged_headers['x-no-account-id'] === null) {
merged_headers['x-no-account-id'] = 'Nothing to See Here';
}
}
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
const jwt =
headers_cleaned['jwt'] || headers_cleaned['JWT'] || api_cfg['jwt'];
if (
jwt &&
!headers_cleaned['Authorization'] &&
!headers_cleaned['authorization']
) {
headers_cleaned['Authorization'] = `Bearer ${jwt}`;
}
headers_cleaned['Content-Type'] = 'application/json';
if (log_lvl > 1) {
console.log('Final cleaned headers:', headers_cleaned);
}
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++) {
// Keep timeout handle at attempt scope so catch can always clear it.
let timeoutId: ReturnType<typeof setTimeout> | null = null;
try {
const controller = new AbortController();
// AbortError alone is ambiguous. Track helper-timeout aborts so
// caller/navigation aborts can still fail fast with no retry.
let did_timeout_abort = false;
timeoutId = setTimeout(() => {
did_timeout_abort = true;
console.error(
`API DELETE request timed out after ${timeout}ms.`
);
controller.abort();
}, timeout);
const fetchOptions: RequestInit = {
method: 'DELETE',
headers: headers_cleaned,
body:
Object.keys(data).length > 0
? JSON.stringify(data)
: undefined,
signal: controller.signal
};
const response = await fetch_method(
url.toString(),
fetchOptions
).catch(function (error: any) {
if (
error?.name === 'AbortError' ||
error?.name === 'TypeError' ||
error?.message?.includes('aborted')
) {
if (log_lvl > 1) {
console.log(
'API DELETE: Request aborted or browser-terminated.',
error
);
}
return error;
}
console.log(
'API DELETE Object *fetch* request was aborted or failed in an unexpected way.',
error
);
return error;
});
if (timeoutId) clearTimeout(timeoutId);
// Error object was returned from fetch catch block; decide retry class.
if (
response instanceof Error ||
(response &&
(response.name === 'AbortError' ||
response.name === 'TypeError'))
) {
if (response.name === 'AbortError') {
if (did_timeout_abort) {
throw new Error(
`Timeout abort (attempt ${attempt}/${retry_count}) after ${timeout}ms`
);
}
return false;
}
throw new Error(
`Network error (attempt ${attempt}): ${response.message}`
);
}
if (!response) {
throw new Error(
`HTTP fetch request was aborted or failed in an unexpected way! URL = ${url.toString()}`
);
}
if (log_lvl) {
console.log(
`Response: status=${response.status} attempt=${attempt}`
);
}
if (!response.ok) {
if (response.status === 404) {
console.warn('404 Not Found. Returning null.');
return null;
}
const errorBody = await response.text();
console.error(
`HTTP error! status: ${response.status}`,
errorBody
);
// Fail fast on client/auth/validation failures.
if (
response.status === 400 ||
response.status === 401 ||
response.status === 403 ||
response.status === 422
) {
if (response.status === 401 || response.status === 403) {
console.warn(
`AUTH DIAGNOSTICS (DELETE): Headers sent for ${endpoint}:`,
{
has_api_key: !!headers_cleaned['x-aether-api-key'],
has_account_id: !!headers_cleaned['x-account-id']
}
);
// Signal the root layout to show the session-expired banner.
if (browser) ae_auth_error.set({ type: 'expired', ts: Date.now() });
}
return false;
}
throw new Error(
`HTTP error! status: ${response.status} - ${errorBody}`
);
}
const json = await response.json();
if (log_lvl > 1) {
console.log('Response JSON:', json);
}
// Return the response data or metadata
// Robustly handle V3 response envelopes
return return_meta
? json
: json.data !== undefined
? json.data
: json;
} catch (error) {
// Ensure per-attempt timeout is always cleared on failure.
if (timeoutId) clearTimeout(timeoutId);
console.error(`API DELETE error on attempt ${attempt}:`, error);
if (attempt === retry_count) {
console.error('Max retry attempts reached. Returning false.');
return false;
}
// Backoff before retrying. Caps at 8s to match GET/POST/PATCH policy.
const delay_ms = Math.min(2000 * attempt, 8000);
console.log(
`API DELETE: Retrying in ${delay_ms}ms... (attempt ${attempt}/${retry_count})`
);
await new Promise<void>((resolve) => setTimeout(resolve, delay_ms));
}
}
};