137 lines
4.9 KiB
TypeScript
137 lines
4.9 KiB
TypeScript
// Helpers for turning failed fetch() responses into actionable, user-friendly
|
|
// error messages (especially for Cloudflare quota / rate-limit pages).
|
|
|
|
export const httpErrorScripts = `
|
|
(function () {
|
|
function extractRayIdFromText(text) {
|
|
const match = String(text || '').match(/ray id\\s*[:#]?\\s*([a-z0-9-]+)/i);
|
|
return match ? match[1] : '';
|
|
}
|
|
|
|
function compact(text, maxLen) {
|
|
const str = String(text || '');
|
|
if (str.length <= maxLen) return str;
|
|
return str.slice(0, Math.max(0, maxLen - 3)) + '...';
|
|
}
|
|
|
|
function classifyCloudflareError(status, text) {
|
|
const lower = String(text || '').toLowerCase();
|
|
const looksLikeCloudflare =
|
|
lower.includes('cloudflare') ||
|
|
lower.includes('ray id') ||
|
|
lower.includes('cf-ray');
|
|
|
|
if (!looksLikeCloudflare) return null;
|
|
|
|
const isRateLimited =
|
|
status === 429 ||
|
|
lower.includes('error 1015') ||
|
|
lower.includes('rate limited') ||
|
|
lower.includes('you are being rate limited');
|
|
|
|
const isQuota =
|
|
lower.includes('worker') &&
|
|
(lower.includes('exceeded') ||
|
|
lower.includes('quota') ||
|
|
lower.includes('limit') ||
|
|
lower.includes('requests'));
|
|
|
|
const isBlocked =
|
|
status === 403 &&
|
|
(lower.includes('access denied') || lower.includes('forbidden'));
|
|
|
|
if (isRateLimited) return { kind: 'rate_limit', label: 'Cloudflare rate limit' };
|
|
if (isQuota) return { kind: 'quota', label: 'Cloudflare plan limit' };
|
|
if (isBlocked) return { kind: 'blocked', label: 'Cloudflare security block' };
|
|
|
|
return { kind: 'cloudflare', label: 'Cloudflare error page' };
|
|
}
|
|
|
|
function buildHelpfulErrorMessage(prefix, status, headers, text, json) {
|
|
const safePrefix = prefix ? String(prefix) : 'Request failed';
|
|
const cfRayHeader = headers && headers.get ? (headers.get('cf-ray') || '') : '';
|
|
const retryAfter = headers && headers.get ? (headers.get('retry-after') || '') : '';
|
|
|
|
// Prefer our own API's structured error first.
|
|
let apiError = '';
|
|
if (json && typeof json === 'object') {
|
|
if (typeof json.error === 'string') apiError = json.error;
|
|
else if (json.error && typeof json.error.message === 'string') apiError = json.error.message;
|
|
}
|
|
|
|
if (apiError) {
|
|
const parts = [safePrefix + ': ' + apiError, '(HTTP ' + status + ')'];
|
|
if (cfRayHeader) parts.push('cf-ray ' + cfRayHeader);
|
|
return parts.join(' ');
|
|
}
|
|
|
|
const cf = classifyCloudflareError(status, text);
|
|
if (cf) {
|
|
const ray = cfRayHeader || extractRayIdFromText(text);
|
|
const base = safePrefix + ': ' + cf.label + ' (HTTP ' + status + ').';
|
|
let hint = '';
|
|
if (cf.kind === 'rate_limit') {
|
|
hint = 'Try again in a bit, or delete smaller batches.';
|
|
} else if (cf.kind === 'quota') {
|
|
hint = 'It looks like you hit a plan quota/limit. Try again later or check Cloudflare usage.';
|
|
} else if (cf.kind === 'blocked') {
|
|
hint = 'Cloudflare blocked the request. Check WAF/rules and logs.';
|
|
} else {
|
|
hint = 'Please try again; if it persists, check Cloudflare logs/usage.';
|
|
}
|
|
|
|
const extras = [];
|
|
if (retryAfter) extras.push('retry-after ' + retryAfter + 's');
|
|
if (ray) extras.push('cf-ray ' + ray);
|
|
const extra = extras.length ? ' (' + extras.join(', ') + ')' : '';
|
|
return base + ' ' + hint + extra;
|
|
}
|
|
|
|
const snippet = compact(String(text || '').replace(/\\s+/g, ' ').trim(), 140);
|
|
const fallbackParts = [safePrefix + ' (HTTP ' + status + ')'];
|
|
if (snippet) fallbackParts.push('- ' + snippet);
|
|
if (cfRayHeader) fallbackParts.push('(cf-ray ' + cfRayHeader + ')');
|
|
return fallbackParts.join(' ');
|
|
}
|
|
|
|
async function parseJsonResponseOrThrow(res, opts) {
|
|
const options = opts || {};
|
|
const prefix = options.prefix ? String(options.prefix) : 'Request failed';
|
|
|
|
const contentType = String(res.headers.get('content-type') || '').toLowerCase();
|
|
let text = '';
|
|
try {
|
|
text = await res.text();
|
|
} catch (e) {
|
|
text = '';
|
|
}
|
|
|
|
const trimmed = String(text || '').trim();
|
|
let json = null;
|
|
if (
|
|
trimmed &&
|
|
(contentType.includes('application/json') ||
|
|
trimmed.startsWith('{') ||
|
|
trimmed.startsWith('['))
|
|
) {
|
|
try {
|
|
json = JSON.parse(trimmed);
|
|
} catch (e) {
|
|
json = null;
|
|
}
|
|
}
|
|
|
|
if (!res.ok) {
|
|
throw new Error(buildHelpfulErrorMessage(prefix, res.status, res.headers, text, json));
|
|
}
|
|
|
|
if (json !== null) return json;
|
|
if (options && options.allowText) return { text: text };
|
|
throw new Error(prefix + ': Unexpected response format (HTTP ' + res.status + ').');
|
|
}
|
|
|
|
// Expose globally for inline route scripts.
|
|
window.parseJsonResponseOrThrow = parseJsonResponseOrThrow;
|
|
})();
|
|
`;
|