427 lines
14 KiB
JavaScript
427 lines
14 KiB
JavaScript
import { rewriteUrl } from './url-rewrite.mjs';
|
|
|
|
const DEFAULT_REQUEST_TIMEOUT = 15000; // For example, with 3 retries: 15s+1s+15s+2s+15s+5s+15s = 68s
|
|
const inflightRequests = new Map();
|
|
const responseCache = new Map();
|
|
|
|
// Centralized utilities for handling errors in Promise contexts
|
|
const safeJson = async (url, params) => {
|
|
try {
|
|
const result = await json(url, params);
|
|
// Return an object with both data and url if params.returnUrl is true
|
|
if (params?.returnUrl) {
|
|
return result;
|
|
}
|
|
// If caller didn't specify returnUrl, result is the raw API response
|
|
return result;
|
|
} catch (_error) {
|
|
// Error already logged in fetchAsync; return null to be "safe"
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const safeText = async (url, params) => {
|
|
try {
|
|
const result = await text(url, params);
|
|
// Return an object with both data and url if params.returnUrl is true
|
|
if (params?.returnUrl) {
|
|
return result;
|
|
}
|
|
// If caller didn't specify returnUrl, result is the raw API response
|
|
return result;
|
|
} catch (_error) {
|
|
// Error already logged in fetchAsync; return null to be "safe"
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const safeBlob = async (url, params) => {
|
|
try {
|
|
const result = await blob(url, params);
|
|
// Return an object with both data and url if params.returnUrl is true
|
|
if (params?.returnUrl) {
|
|
return result;
|
|
}
|
|
// If caller didn't specify returnUrl, result is the raw API response
|
|
return result;
|
|
} catch (_error) {
|
|
// Error already logged in fetchAsync; return null to be "safe"
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const safePromiseAll = async (promises) => {
|
|
try {
|
|
const results = await Promise.allSettled(promises);
|
|
|
|
return results.map((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
return result.value;
|
|
}
|
|
// Log rejected promises for debugging (except AbortErrors which are expected)
|
|
if (result.reason?.name !== 'AbortError') {
|
|
console.warn(`Promise ${index} rejected:`, result.reason?.message || result.reason);
|
|
}
|
|
return null;
|
|
});
|
|
} catch (error) {
|
|
console.error('safePromiseAll encountered an unexpected error:', error);
|
|
// Return array of nulls matching the input length
|
|
return new Array(promises.length).fill(null);
|
|
}
|
|
};
|
|
|
|
const json = (url, params) => fetchAsync(url, 'json', params);
|
|
const text = (url, params) => fetchAsync(url, 'text', params);
|
|
const blob = (url, params) => fetchAsync(url, 'blob', params);
|
|
|
|
// Hosts that don't allow custom User-Agent headers due to CORS restrictions
|
|
const USER_AGENT_EXCLUDED_HOSTS = [
|
|
'geocode.arcgis.com',
|
|
'services.arcgis.com',
|
|
];
|
|
|
|
const classifyRequest = (_url) => {
|
|
const url = new URL(_url, window.location.origin);
|
|
if (url.hostname.includes('api.weather.gov')) {
|
|
if (url.pathname.includes('/alerts/active')) return 'weatherGovAlerts';
|
|
return 'weatherGovGeneral';
|
|
}
|
|
if (url.hostname.includes('api.open-meteo.com')) {
|
|
return 'openMeteo';
|
|
}
|
|
return 'default';
|
|
};
|
|
|
|
const getRequestPolicy = (requestClass, providedParams = {}) => {
|
|
const defaults = {
|
|
default: {
|
|
timeout: DEFAULT_REQUEST_TIMEOUT,
|
|
retryCount: 3,
|
|
cacheTtlMs: 0,
|
|
},
|
|
weatherGovAlerts: {
|
|
timeout: 8000,
|
|
retryCount: 1,
|
|
cacheTtlMs: 30000,
|
|
},
|
|
weatherGovGeneral: {
|
|
timeout: 10000,
|
|
retryCount: 2,
|
|
cacheTtlMs: 60000,
|
|
},
|
|
openMeteo: {
|
|
timeout: 8000,
|
|
retryCount: 1,
|
|
cacheTtlMs: 60000,
|
|
},
|
|
};
|
|
|
|
const policy = defaults[requestClass] ?? defaults.default;
|
|
return {
|
|
timeout: providedParams.timeout ?? policy.timeout,
|
|
retryCount: providedParams.retryCount ?? policy.retryCount,
|
|
cacheTtlMs: providedParams.cacheTtlMs ?? policy.cacheTtlMs,
|
|
};
|
|
};
|
|
|
|
const buildRequestKey = (url, responseType, params) => `${(params.method ?? 'GET').toUpperCase()}:${responseType}:${url.toString()}`;
|
|
|
|
const getCachedResponse = (key) => {
|
|
const cached = responseCache.get(key);
|
|
if (!cached) return null;
|
|
if (Date.now() >= cached.expiresAt) {
|
|
responseCache.delete(key);
|
|
return null;
|
|
}
|
|
return cached.data;
|
|
};
|
|
|
|
const setCachedResponse = (key, data, ttlMs) => {
|
|
if (!ttlMs || ttlMs <= 0) return;
|
|
responseCache.set(key, {
|
|
data,
|
|
expiresAt: Date.now() + ttlMs,
|
|
});
|
|
};
|
|
|
|
const isTransientError = (error) => error?.name === 'TimeoutError'
|
|
|| error?.message?.includes('429')
|
|
|| error?.message?.includes('500')
|
|
|| error?.message?.includes('502')
|
|
|| error?.message?.includes('503')
|
|
|| error?.message?.includes('504');
|
|
|
|
const fetchAsync = async (_url, responseType, _params = {}) => {
|
|
const headers = {};
|
|
const requestClass = classifyRequest(_url);
|
|
const policy = getRequestPolicy(requestClass, _params);
|
|
|
|
const checkUrl = new URL(_url, window.location.origin);
|
|
const shouldExcludeUserAgent = USER_AGENT_EXCLUDED_HOSTS.some((host) => checkUrl.hostname.includes(host));
|
|
|
|
// User-Agent handling:
|
|
// - Server mode (with caching proxy): Add User-Agent for all requests except excluded hosts
|
|
// - Static mode (direct requests): Only add User-Agent for api.weather.gov, avoiding CORS preflight issues with other services
|
|
const shouldAddUserAgent = !shouldExcludeUserAgent && (window.WS4KP_SERVER_AVAILABLE || _url.toString().match(/api\.weather\.gov/));
|
|
if (shouldAddUserAgent) {
|
|
headers['user-agent'] = 'WeatherStar 4000+: Linhanced; marky611@gmail.com';
|
|
}
|
|
|
|
// combine default and provided parameters
|
|
const params = {
|
|
method: 'GET',
|
|
mode: 'cors',
|
|
type: 'GET',
|
|
retryCount: policy.retryCount,
|
|
timeout: policy.timeout,
|
|
cacheTtlMs: policy.cacheTtlMs,
|
|
..._params,
|
|
headers,
|
|
requestClass,
|
|
};
|
|
|
|
// rewrite URLs for various services to use the backend proxy server for proper caching (and request logging)
|
|
const url = rewriteUrl(_url);
|
|
// match the security protocol when not on localhost
|
|
// url.protocol = window.location.hostname === 'localhost' ? url.protocol : window.location.protocol;
|
|
// add parameters if necessary
|
|
if (params.data) {
|
|
Object.keys(params.data).forEach((key) => {
|
|
// get the value
|
|
const value = params.data[key];
|
|
// add to the url
|
|
url.searchParams.append(key, value);
|
|
});
|
|
}
|
|
|
|
const shouldUseTransportCache = params.method.toUpperCase() === 'GET' && !params.returnUrl;
|
|
const requestKey = shouldUseTransportCache ? buildRequestKey(url, responseType, params) : null;
|
|
const cachedData = shouldUseTransportCache ? getCachedResponse(requestKey) : null;
|
|
if (cachedData !== null) return cachedData;
|
|
if (shouldUseTransportCache && inflightRequests.has(requestKey)) {
|
|
return inflightRequests.get(requestKey);
|
|
}
|
|
|
|
const executeFetch = async () => {
|
|
// make the request
|
|
try {
|
|
const response = await doFetch(url, params);
|
|
|
|
// check for ok response
|
|
if (!response.ok) throw new Error(`Fetch error ${response.status} ${response.statusText} while fetching ${response.url}`);
|
|
// process the response based on type
|
|
let result;
|
|
switch (responseType) {
|
|
case 'json':
|
|
result = await response.json();
|
|
break;
|
|
case 'text':
|
|
result = await response.text();
|
|
break;
|
|
case 'blob':
|
|
result = await response.blob();
|
|
break;
|
|
default:
|
|
result = response;
|
|
}
|
|
|
|
if (shouldUseTransportCache) {
|
|
setCachedResponse(requestKey, result, params.cacheTtlMs);
|
|
}
|
|
|
|
// Return both data and URL if requested
|
|
if (params.returnUrl) {
|
|
return {
|
|
data: result,
|
|
url: response.url,
|
|
};
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
if (shouldUseTransportCache && cachedData !== null && isTransientError(error)) {
|
|
return cachedData;
|
|
}
|
|
|
|
// Enhanced error handling for different error types
|
|
if (error.name === 'AbortError') {
|
|
console.log(`🛑 Fetch aborted for ${_url} (background tab throttling?)`);
|
|
return null;
|
|
} if (error.name === 'TimeoutError') {
|
|
console.warn(`⏱️ Request timeout for ${_url} (${error.message})`);
|
|
} else if (error.message.includes('429')) {
|
|
console.warn(`🐢 Rate limited for ${_url}`);
|
|
} else if (error.message.includes('502')) {
|
|
console.warn(`🚪 Bad Gateway error for ${_url}`);
|
|
} else if (error.message.includes('503')) {
|
|
console.warn(`⌛ Temporarily unavailable for ${_url}`);
|
|
} else if (error.message.includes('504')) {
|
|
console.warn(`⏱️ Gateway Timeout for ${_url}`);
|
|
} else if (error.message.includes('500')) {
|
|
console.warn(`💥 Internal Server Error for ${_url}`);
|
|
} else if (error.message.includes('CORS') || error.message.includes('Access-Control')) {
|
|
console.warn(`🔒 CORS or Access Control error for ${_url}`);
|
|
} else {
|
|
console.warn(`❌ Fetch failed for ${_url} (${error.message})`);
|
|
}
|
|
|
|
if (!error.status) error.status = 0;
|
|
if (!error.responseJSON) error.responseJSON = null;
|
|
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
if (!shouldUseTransportCache) return executeFetch();
|
|
|
|
const inflightPromise = executeFetch().finally(() => {
|
|
inflightRequests.delete(requestKey);
|
|
});
|
|
inflightRequests.set(requestKey, inflightPromise);
|
|
return inflightPromise;
|
|
};
|
|
|
|
// fetch with retry and back-off
|
|
const doFetch = (url, params, originalRetryCount = null) => new Promise((resolve, reject) => {
|
|
// On the first call, store the retry count for later logging
|
|
const initialRetryCount = originalRetryCount ?? params.retryCount;
|
|
|
|
// Create AbortController for timeout
|
|
const controller = new AbortController();
|
|
const startTime = Date.now();
|
|
const timeoutId = setTimeout(() => {
|
|
controller.abort();
|
|
}, params.timeout);
|
|
|
|
// Add signal to fetch params
|
|
const fetchParams = {
|
|
...params,
|
|
signal: controller.signal,
|
|
};
|
|
|
|
// Shared retry logic to avoid duplication
|
|
const attemptRetry = (reason, retryAfterMs = null) => {
|
|
// Safety check for params
|
|
if (!params || typeof params.retryCount !== 'number') {
|
|
console.error(`❌ Invalid params for retry: ${url}`);
|
|
return reject(new Error('Invalid retry parameters'));
|
|
}
|
|
|
|
const retryAttempt = initialRetryCount - params.retryCount + 1;
|
|
const remainingRetries = params.retryCount - 1;
|
|
const delayMs = retryDelay(retryAttempt, params.requestClass, retryAfterMs);
|
|
|
|
console.warn(`🔄 Retry ${retryAttempt}/${initialRetryCount} for ${url} - ${reason} (retrying in ${delayMs}ms, ${remainingRetries} retr${remainingRetries === 1 ? 'y' : 'ies'} left)`);
|
|
|
|
// call the "still waiting" function on first retry
|
|
if (params && params.stillWaiting && typeof params.stillWaiting === 'function' && retryAttempt === 1) {
|
|
try {
|
|
params.stillWaiting();
|
|
} catch (callbackError) {
|
|
console.warn(`⚠️ stillWaiting callback error for ${url}:`, callbackError.message);
|
|
}
|
|
}
|
|
// decrement and retry with safe parameter copying
|
|
const newParams = {
|
|
...params,
|
|
retryCount: Math.max(0, params.retryCount - 1), // Ensure retryCount doesn't go negative
|
|
};
|
|
// Use setTimeout directly instead of the delay wrapper to avoid Promise resolution issues
|
|
setTimeout(() => {
|
|
doFetch(url, newParams, initialRetryCount).then(resolve).catch(reject);
|
|
}, delayMs);
|
|
return undefined; // Explicit return for linter
|
|
};
|
|
|
|
fetch(url, fetchParams).then((response) => {
|
|
clearTimeout(timeoutId); // Clear timeout on successful response
|
|
|
|
if (params && params.retryCount > 0 && response.status === 429) {
|
|
const retryAfterHeader = response.headers.get('Retry-After');
|
|
const retryAfterSeconds = Number.parseInt(retryAfterHeader, 10);
|
|
const retryAfterMs = Number.isFinite(retryAfterSeconds) ? retryAfterSeconds * 1000 : null;
|
|
return attemptRetry(`Rate limited 429 ${response.statusText}`, retryAfterMs);
|
|
}
|
|
|
|
// Retry 500 status codes if we have retries left
|
|
if (params && params.retryCount > 0 && response.status >= 500 && response.status <= 599) {
|
|
let errorType = 'Server error';
|
|
if (response.status === 502) {
|
|
errorType = 'Bad Gateway';
|
|
} else if (response.status === 503) {
|
|
errorType = 'Service Unavailable';
|
|
} else if (response.status === 504) {
|
|
errorType = 'Gateway Timeout';
|
|
}
|
|
return attemptRetry(`${errorType} ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
// Log when we're out of retries for server errors
|
|
// if (response.status >= 500 && response.status <= 599) {
|
|
// console.warn(`⚠️ Server error ${response.status} ${response.statusText} for ${url} - no retries remaining`);
|
|
// }
|
|
|
|
// successful response or out of retries
|
|
return resolve(response);
|
|
}).catch((error) => {
|
|
clearTimeout(timeoutId); // Clear timeout on error
|
|
|
|
// Enhance AbortError detection by checking if we're near the timeout duration
|
|
if (error.name === 'AbortError') {
|
|
const duration = Date.now() - startTime;
|
|
const isLikelyTimeout = duration >= (params.timeout - 1000); // Within 1 second of timeout
|
|
|
|
// Convert likely timeouts to TimeoutError for better error reporting
|
|
if (isLikelyTimeout) {
|
|
const reason = `Request timeout after ${Math.round(duration / 1000)}s`;
|
|
if (params && params.retryCount > 0) {
|
|
return attemptRetry(reason);
|
|
}
|
|
// Convert to a timeout error for better error reporting
|
|
const timeoutError = new Error(`Request timeout after ${Math.round(duration / 1000)}s`);
|
|
timeoutError.name = 'TimeoutError';
|
|
reject(timeoutError);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// Retry network errors if we have retries left
|
|
if (params && params.retryCount > 0 && error.name !== 'AbortError') {
|
|
const reason = error.name === 'TimeoutError' ? 'Request timeout' : `Network error: ${error.message}`;
|
|
return attemptRetry(reason);
|
|
}
|
|
|
|
// out of retries or AbortError - reject
|
|
reject(error);
|
|
return undefined; // Explicit return for linter
|
|
});
|
|
});
|
|
|
|
const retryDelay = (retryNumber, requestClass = 'default', retryAfterMs = null) => {
|
|
if (retryAfterMs !== null) {
|
|
return retryAfterMs + Math.floor(Math.random() * 400);
|
|
}
|
|
|
|
if (requestClass === 'openMeteo') {
|
|
return 5000 + Math.floor(Math.random() * 400);
|
|
}
|
|
|
|
switch (retryNumber) {
|
|
case 1: return 1000 + Math.floor(Math.random() * 400);
|
|
case 2: return 2000 + Math.floor(Math.random() * 400);
|
|
case 3: return 5000 + Math.floor(Math.random() * 600);
|
|
case 4: return 10_000 + Math.floor(Math.random() * 1000);
|
|
default: return 30_000 + Math.floor(Math.random() * 1000);
|
|
}
|
|
};
|
|
|
|
export {
|
|
json,
|
|
text,
|
|
blob,
|
|
safeJson,
|
|
safeText,
|
|
safeBlob,
|
|
safePromiseAll,
|
|
};
|