fix(admin): improve Cloudflare limit error messages

This commit is contained in:
Young Lee
2026-02-06 14:37:07 -08:00
parent 25a942e203
commit 4b7bb8faf1
3 changed files with 193 additions and 28 deletions
+54 -28
View File
@@ -1349,10 +1349,15 @@ app.get("/", async (c) => {
body: JSON.stringify({ feedIds: batch }), body: JSON.stringify({ feedIds: batch }),
}); });
const data = await res.json().catch(() => ({})); let data = {};
if (!res.ok) { if (window.parseJsonResponseOrThrow) {
const message = data && data.error ? String(data.error) : ('Request failed (' + res.status + ')'); data = await window.parseJsonResponseOrThrow(res, { prefix: 'Bulk feed delete failed' });
throw new Error(message); } else {
data = await res.json().catch(() => ({}));
if (!res.ok) {
const message = data && data.error ? String(data.error) : ('Bulk feed delete failed (HTTP ' + res.status + ')');
throw new Error(message);
}
} }
const deletedIds = Array.isArray(data.deletedFeedIds) ? data.deletedFeedIds : batch; const deletedIds = Array.isArray(data.deletedFeedIds) ? data.deletedFeedIds : batch;
@@ -1376,7 +1381,7 @@ app.get("/", async (c) => {
if (failed.length > 0) { if (failed.length > 0) {
if (window.showToast) { if (window.showToast) {
window.showToast( window.showToast(
'Deleted ' + deletedTotal + ' feed(s). ' + failed.length + ' failed (still visible).', 'Deleted ' + deletedTotal + ' feed(s). ' + failed.length + ' failed (still visible). Try again; Cloudflare limits can cause temporary failures.',
{ type: 'error', duration: 6500 }, { type: 'error', duration: 6500 },
); );
} }
@@ -1386,7 +1391,7 @@ app.get("/", async (c) => {
} catch (error) { } catch (error) {
if (toast && toast.dismiss) toast.dismiss(); if (toast && toast.dismiss) toast.dismiss();
if (window.showToast) { if (window.showToast) {
window.showToast('Bulk delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', duration: 7000 }); window.showToast((error && error.message) ? error.message : 'Bulk feed delete failed.', { type: 'error', duration: 7500 });
} }
} finally { } finally {
FEED_BULK_DELETE_IN_PROGRESS = false; FEED_BULK_DELETE_IN_PROGRESS = false;
@@ -1757,16 +1762,15 @@ app.post("/feeds/:feedId/purge", async (c) => {
app.post("/feeds/bulk-delete", async (c) => { app.post("/feeds/bulk-delete", async (c) => {
const env = c.env as unknown as Env; const env = c.env as unknown as Env;
const emailStorage = env.EMAIL_STORAGE; const emailStorage = env.EMAIL_STORAGE;
const contentType = c.req.header("Content-Type") || "";
const wantsJson =
contentType.includes("application/json") ||
(c.req.header("Accept") || "").includes("application/json");
try { try {
const contentType = c.req.header("Content-Type") || ""; if (wantsJson) {
const wantsJson = const body = (await c.req.json().catch(() => null)) as {
contentType.includes("application/json") || feedIds?: unknown;
(c.req.header("Accept") || "").includes("application/json");
if (wantsJson) {
const body = (await c.req.json().catch(() => null)) as {
feedIds?: unknown;
} | null; } | null;
const rawIds = Array.isArray(body?.feedIds) ? body?.feedIds : []; const rawIds = Array.isArray(body?.feedIds) ? body?.feedIds : [];
@@ -1868,7 +1872,16 @@ app.post("/feeds/bulk-delete", async (c) => {
); );
} catch (error) { } catch (error) {
console.error("Error bulk deleting feeds:", error); console.error("Error bulk deleting feeds:", error);
return c.text("Error bulk deleting feeds. Please try again.", 400); return wantsJson
? c.json(
{
ok: false,
error:
"Bulk feed delete failed. This can happen if Cloudflare is rate-limiting requests or if the Worker hit a plan quota. Please try again.",
},
500,
)
: c.text("Error bulk deleting feeds. Please try again.", 500);
} }
}); });
@@ -2674,10 +2687,15 @@ app.get("/feeds/:feedId/emails", async (c) => {
body: JSON.stringify({ emailKeys: batch }), body: JSON.stringify({ emailKeys: batch }),
}); });
const data = await res.json().catch(() => ({})); let data = {};
if (!res.ok) { if (window.parseJsonResponseOrThrow) {
const message = data && data.error ? String(data.error) : ('Request failed (' + res.status + ')'); data = await window.parseJsonResponseOrThrow(res, { prefix: 'Bulk email delete failed' });
throw new Error(message); } else {
data = await res.json().catch(() => ({}));
if (!res.ok) {
const message = data && data.error ? String(data.error) : ('Bulk email delete failed (HTTP ' + res.status + ')');
throw new Error(message);
}
} }
const deletedKeys = Array.isArray(data.deletedEmailKeys) ? data.deletedEmailKeys : batch; const deletedKeys = Array.isArray(data.deletedEmailKeys) ? data.deletedEmailKeys : batch;
@@ -2700,7 +2718,7 @@ app.get("/feeds/:feedId/emails", async (c) => {
if (failed.length > 0) { if (failed.length > 0) {
if (window.showToast) { if (window.showToast) {
window.showToast( window.showToast(
'Deleted ' + deletedTotal + ' email(s). ' + failed.length + ' failed (still visible).', 'Deleted ' + deletedTotal + ' email(s). ' + failed.length + ' failed (still visible). Try again; Cloudflare limits can cause temporary failures.',
{ type: 'error', duration: 6500 }, { type: 'error', duration: 6500 },
); );
} }
@@ -2710,7 +2728,7 @@ app.get("/feeds/:feedId/emails", async (c) => {
} catch (error) { } catch (error) {
if (toast && toast.dismiss) toast.dismiss(); if (toast && toast.dismiss) toast.dismiss();
if (window.showToast) { if (window.showToast) {
window.showToast('Bulk delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', duration: 7000 }); window.showToast((error && error.message) ? error.message : 'Bulk email delete failed.', { type: 'error', duration: 7500 });
} }
} finally { } finally {
EMAIL_BULK_DELETE_IN_PROGRESS = false; EMAIL_BULK_DELETE_IN_PROGRESS = false;
@@ -3146,13 +3164,12 @@ app.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
const env = c.env as unknown as Env; const env = c.env as unknown as Env;
const emailStorage = env.EMAIL_STORAGE; const emailStorage = env.EMAIL_STORAGE;
const feedId = c.req.param("feedId"); const feedId = c.req.param("feedId");
const contentType = c.req.header("Content-Type") || "";
const wantsJson =
contentType.includes("application/json") ||
(c.req.header("Accept") || "").includes("application/json");
try { try {
const contentType = c.req.header("Content-Type") || "";
const wantsJson =
contentType.includes("application/json") ||
(c.req.header("Accept") || "").includes("application/json");
const feedMetadataKey = `feed:${feedId}:metadata`; const feedMetadataKey = `feed:${feedId}:metadata`;
const feedMetadata = (await emailStorage.get(feedMetadataKey, { const feedMetadata = (await emailStorage.get(feedMetadataKey, {
type: "json", type: "json",
@@ -3236,7 +3253,16 @@ app.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
); );
} catch (error) { } catch (error) {
console.error("Error bulk deleting emails:", error); console.error("Error bulk deleting emails:", error);
return c.text("Error bulk deleting emails. Please try again.", 400); return wantsJson
? c.json(
{
ok: false,
error:
"Bulk email delete failed. This can happen if Cloudflare is rate-limiting requests or if the Worker hit a plan quota. Please try again.",
},
500,
)
: c.text("Error bulk deleting emails. Please try again.", 500);
} }
}); });
+136
View File
@@ -0,0 +1,136 @@
// 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;
})();
`;
+3
View File
@@ -5,10 +5,12 @@ import { modalScripts, emailViewScripts, initScripts } from "./interactions";
import { clipboardScripts } from "./clipboard"; import { clipboardScripts } from "./clipboard";
import { authHelpers } from "./auth"; import { authHelpers } from "./auth";
import { toastScripts } from "./toast"; import { toastScripts } from "./toast";
import { httpErrorScripts } from "./httpErrors";
// Combine all scripts into a single JavaScript string // Combine all scripts into a single JavaScript string
export const interactiveScripts = ` export const interactiveScripts = `
${toastScripts} ${toastScripts}
${httpErrorScripts}
${modalScripts} ${modalScripts}
${emailViewScripts} ${emailViewScripts}
${clipboardScripts} ${clipboardScripts}
@@ -23,4 +25,5 @@ export {
clipboardScripts, clipboardScripts,
authHelpers, authHelpers,
toastScripts, toastScripts,
httpErrorScripts,
}; };