diff --git a/CHANGELOG.md b/CHANGELOG.md index 15dfe11..d7b9fd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ ## 2026-10-15 +- Savr now tells you when your CORS proxy itself stops working (#86), instead of saves and images just failing. A capped Cloudflare worker (error 1027) answers without CORS headers, so the browser hides the status; after a network-level failure Savr probes the proxy prefix with an empty target, with and without CORS, and reports only what it saw: "rate-limiting (HTTP 429)", "responding but not working" (likely a usage limit or misconfiguration) or "isn't responding". A non-blocking banner on the article list and add screens links to the setting (and, on the shared proxy, to setting up your own); Preferences and Diagnostics show a status line, and Diagnostics the raw probe evidence plus a per-device count of proxy requests today. A visible 429 is probed too, since the proxy passes a site's own 429 through: only a 429 on the probe itself blames the proxy, otherwise just that URL backs off (previously any 429 paused every proxied request). A bad verdict pauses proxied requests with the existing breaker; probes run at most once a minute. While the proxy is failing, a failed save says so instead of offering to report the article, and on desktop the banner notes that extension saves need no proxy. No automatic proxy switching and no guessed reset times + - The shared CORS proxy now only serves allowlisted calling sites — savr.link, Netlify preview/branch deploys (`*--savrlist.netlify.app`) and localhost — via a new optional `ALLOWED_ORIGINS` setting. It still fetches articles from any public site; other websites calling it get a 404, recorded in the stats. Self-hosted Savr frontends on their own domain need their own proxy. Unset (the default for self-hosted proxies) changes nothing (ADR 0006; rate limiting tracked in #117) - The app is translated: the UI now speaks English and Spanish. The language is detected from the browser (`es*` → Spanish) and can be switched in Preferences → Language, where the choice is remembered. All screens, dialogs, snackbars, tooltips and empty states are covered, as are the article card/read-time strings and the ingestion progress messages produced by `lib/`; `{{placeholders}}` and plurals are interpolated per language. LLM summaries are asked to answer in Spanish when the UI is in Spanish, and the text-to-speech voice preference follows the language. `src/i18n.test.ts` enforces en/es key and placeholder parity, and `tests/e2e/spanish.spec.ts` covers loading, switching and persistence (the e2e browser locale is pinned to `en-US` so the existing English assertions stay deterministic) - The browser extension is localised too, via the standard `_locales/{en,es}/messages.json` + `chrome.i18n` mechanism (`default_locale: en`); it follows the browser's language. The image relay keeps an English fallback for its error string diff --git a/README.md b/README.md index 02bcb55..af06afc 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,17 @@ Savr uses a CORS proxy server that acts as a middleman. Instead of your browser Savr is also polite about how it uses the proxy: identical requests in flight are de-duplicated, a URL that keeps failing is backed off (a 404 is not retried automatically), and if the proxy itself is rate-limited Savr pauses requests for a cooldown instead of hammering it. If an image can't be downloaded when you save an article, it is replaced with a local placeholder, so reading the saved article never contacts the original site again. +**When the proxy stops working** +If the proxy itself fails (rather than the site you're saving), Savr shows a banner on the article list and the add screen, and a status line under **Preferences → CORS Proxy**, saying only what it observed: + +| Observed | Message | +|---|---| +| The proxy itself answers with HTTP 429 | "Your CORS proxy is rate-limiting requests (HTTP 429)." | +| Something answers, but not the proxy's own code | "Your CORS proxy is responding but not working. It may have hit a usage limit or be misconfigured." | +| Nothing answers while you're online | "Your CORS proxy isn't responding. Check the URL in Preferences." | + +The middle case is what a capped Cloudflare worker looks like: past the free plan's 100k requests/day the edge serves its own error page (1027) without CORS headers, so the browser hides the status. Savr tells it apart by probing the proxy prefix (with an empty target) twice, once normally and once with `no-cors`: our worker answers with CORS headers even on errors, while a `no-cors` request succeeds whenever anything answered. A 429 is probed too, because the proxy passes a site's own 429 straight through: only if the probe is also refused with 429 is the proxy blamed; otherwise just that page or image backs off. A probe runs at most once per minute, only after a network-level failure or a 429, and a bad verdict pauses proxied requests for a cooldown. Savr never switches proxies by itself and never guesses a reset time. While the proxy is failing, a failed save says so ("Couldn't save: your CORS proxy isn't working…") instead of offering to report the article, which isn't at fault; on desktop the banner also points out that saving with the browser extension needs no proxy. The banner clears as soon as a request or probe succeeds; **Diagnostics → CORS Proxy Health** shows the raw probe results, and roughly how many proxy requests this device sent today (other devices share the same proxy, so it's only a hint). + **Why bring your own proxy?** While Savr provides a default proxy, you can configure your own CORS proxy server for: diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f1914b6..bff29d9 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -119,6 +119,7 @@ rm -f tests/e2e/.test-env.json | `main-page.spec.ts` | Main page UI tests | Requires React hydration | | `add-article-dialog.spec.ts` | Add-article dialog interactions | Requires React hydration | | `failure-report.spec.ts` | Failed-capture report dialog: dismiss, prefilled GitHub issue, mobile reachability | Needs `VITE_FAILURE_REPORT_REPO` (set by the Playwright web-server env) | +| `proxy-health.spec.ts` | CORS proxy health banner (#86): visible 429 from the proxy, a target's own 429 passed through a working proxy (no banner, report dialog still offered), capped-edge answer ("Couldn't save" notice, no report dialog, extension hint) without CORS headers (real local server — `route.fulfill` adds CORS headers itself), unreachable, and a failing target behind a working proxy (no banner) | Active | | `share-article.spec.ts` | Share menu on a list article: system share sheet (Web Share stubbed) and clipboard fallback | Active | | `ingest-local-article.spec.ts` | Article ingestion via UI, incl. PDF/markdown/image from `test_data/input/` | Serial | | `ingest-failed-image.spec.ts` | A failed image is replaced by a local placeholder in saved HTML (#75) | Serial | @@ -175,7 +176,11 @@ governor in `src/utils/net/fetchGovernor.ts`. The governor: - coalesces concurrent fetches of the same URL into one request, - records failures so a URL is not retried until its `nextAttemptAt` passes (permanent 4xx wait days; transient 5xx/timeout/429 back off exponentially), -- opens a circuit breaker on HTTP 429 so a capped proxy isn't hammered. +- opens a circuit breaker when the proxy itself is failing, so a capped proxy + isn't hammered: on a 429, or a network-level failure with no visible status + (incl. timeouts), it asks `diagnoseProxy` whether the proxy is at fault — + the worker passes a target's own 429 through, so that only backs off the + URL. Without `diagnoseProxy` every 429 trips the breaker. Failure history is persisted in the Dexie `fetchCache` table (db version 6) via `src/utils/net/fetchGovernorStore.ts`. An explicit **Refetch** in the reader @@ -184,9 +189,27 @@ Images that fail during ingest are swapped for a local placeholder (`applyImagePlaceholder` in `lib/src/ingestion.ts`) so saved articles never re-request a dead remote URL. -Unit tests: `src/utils/net/fetchGovernor.test.ts`, +Unit tests: `src/utils/net/fetchGovernor.test.ts`, `src/utils/net/proxyHealth.test.ts`, `lib/__tests__/ingestion-placeholder.test.ts`. +### Proxy health (#86) + +A capped Cloudflare worker (error 1027) is answered by the edge without CORS +headers, so the browser hides its status. `src/utils/net/proxyHealth.ts` (pure, +injected fetch/clock/storage) probes the proxy prefix with an empty target, once +with CORS and once with `no-cors`: CORS 429 → rate-limited; any other CORS +answer → running; only no-cors answers → +"responding but not working"; neither while online → unreachable; offline → no +verdict. Probes are coalesced and throttled (one per minute, i.e. one per +breaker cycle). The monitor instance (`proxyHealth`) and its wiring to the +governor live next to the governor in `src/utils/article/tools.ts`; the UI reads +it via `useProxyHealth` (`ProxyHealthBanner`, Preferences, Diagnostics). A +response that came back through the proxy with CORS headers clears it; changing +the proxy setting resets it (`resetProxyHealth`). It also keeps a per-device +daily count of proxied requests in localStorage (`savr-proxy-requests`). +While a problem is known, `useFailureReport` offers no "report this article" +dialog and the save/refetch snackbars name the proxy instead. + ## Image sources and downloader logging Ingest downloads each image through an ordered list of image sources diff --git a/infra/cors-worker/README.md b/infra/cors-worker/README.md index 883e349..80be193 100644 --- a/infra/cors-worker/README.md +++ b/infra/cors-worker/README.md @@ -156,3 +156,12 @@ cap. The primary defense is the **browser** cache (the `Cache-Control` above makes browsers stop re-requesting) plus the app-side fetch governor (`src/utils/net/fetchGovernor.ts`), which de-dupes, negative-caches and backs off so the app never generates the retry storm in the first place. + +When the cap is hit anyway, the app notices (#86): the 1027 page comes from +Cloudflare's edge, not this worker, so it lacks the CORS headers that every +response from `core.js` carries (its own 4xx/5xx included). The app probes the +configured prefix with an empty target — this worker answers that with a CORS +`400` — once with CORS and once with `no-cors`, and reads "CORS fails, no-cors +succeeds" as "reachable but not running our code". Keep CORS headers on every +response the worker produces, or that probe will misread the worker as down. +See `src/utils/net/proxyHealth.ts`. diff --git a/infra/cors-worker/worker.test.mjs b/infra/cors-worker/worker.test.mjs index 543de8f..1096f82 100644 --- a/infra/cors-worker/worker.test.mjs +++ b/infra/cors-worker/worker.test.mjs @@ -281,6 +281,45 @@ test("rejects a missing url", async () => { assert.equal(response.status, 400); }); +// The app's proxy-health probe (#86) fetches the configured prefix with an +// empty target and reads "answered with CORS headers" as "our code is +// running". Every shape of prefix must get that CORS 400. +test("answers the empty-target health probe with a CORS 400", async () => { + stubGlobals(); + const cases = [ + [`${PROXY}?url=`, { PUBLIC: "true" }], + [`${PROXY}${TOKEN}/?url=`, { PROXY_TOKENS: TOKEN }], + [`${PROXY}?token=${TOKEN}&url=`, { PROXY_TOKENS: TOKEN }], + ]; + for (const [prefix, env] of cases) { + const response = await callWorker(urlRequest(prefix), { env }); + assert.equal(response.status, 400, prefix); + assert.equal(response.headers.get("access-control-allow-origin"), "*", prefix); + } +}); + +// Behind an origin allowlist the probe still reads as "running": an allowed +// caller gets the CORS 400, and a refused one a 404 that still carries CORS +// headers — never an answer the app would mistake for a capped proxy. +test("answers the health probe with CORS headers behind an origin allowlist", async () => { + stubGlobals(); + const env = { PUBLIC: "true", ALLOWED_ORIGINS: "https://savr.link" }; + + const allowed = await callWorker( + urlRequest(`${PROXY}?url=`, { headers: { Origin: "https://savr.link" } }), + { env } + ); + assert.equal(allowed.status, 400); + assert.ok(allowed.headers.get("access-control-allow-origin")); + + const refused = await callWorker( + urlRequest(`${PROXY}?url=`, { headers: { Origin: "https://elsewhere.example" } }), + { env } + ); + assert.equal(refused.status, 404); + assert.ok(refused.headers.get("access-control-allow-origin")); +}); + test("rejects malformed and non-http urls", async () => { stubGlobals(); const invalid = await callWorker(proxyRequest("not a url"), { env: { PUBLIC: "true" } }); @@ -781,7 +820,7 @@ test("origin allowlist is checked before the edge cache", async () => { const allowed = await callWorker(fromOrigin("https://savr.link", "https://example.com/hit.jpg"), { env, cache }); assert.equal(allowed.status, 200); assert.equal(await allowed.text(), "cached"); - assert.equal(allowed.headers.get("access-control-allow-origin"), "https://savr.link"); + assert.ok(allowed.headers.get("access-control-allow-origin")); }); test("origin allowlist leaves preflight and health open", async () => { diff --git a/src/components/ArticleListScreen.tsx b/src/components/ArticleListScreen.tsx index 1414b47..0a3d3f5 100644 --- a/src/components/ArticleListScreen.tsx +++ b/src/components/ArticleListScreen.tsx @@ -50,7 +50,13 @@ import { type ExtensionInfo, } from "~/utils/extension/extensionBridge"; import { trackCapture, trackCaptureFailed } from "~/utils/telemetry"; -import { removeArticle, patchArticleMetadata, loadThumbnail } from "~/utils/article/tools"; +import { + removeArticle, + patchArticleMetadata, + loadThumbnail, + proxyHealth, +} from "~/utils/article/tools"; +import { proxyProblemSummary } from "~/utils/net/proxyHealth"; import { useRemoteStorage } from "./RemoteStorageProvider"; import { useSyncStatus } from "./SyncStatusProvider"; import { useLiveQuery } from "dexie-react-hooks"; @@ -69,6 +75,7 @@ import { normalizeUrl } from "~/utils/net/url"; import { shareArticle } from "~/utils/share"; import FailureReportDialog from "./FailureReportDialog"; import { useTranslation } from "react-i18next"; +import ProxyHealthBanner from "./ProxyHealthBanner"; import { keyframes } from "@mui/system"; @@ -816,7 +823,13 @@ export default function ArticleListScreen() { } catch (error) { console.error(error); trackCaptureFailed(); - enqueueSnackbar(t("list.saveError", { error: errorMessage(error) }), { variant: "error" }); + const proxyProblem = proxyHealth.getState().problem; + enqueueSnackbar( + proxyProblem + ? t("common.proxyHealth.saveFailed", { reason: proxyProblemSummary(t, proxyProblem) }) + : t("list.saveError", { error: errorMessage(error) }), + { variant: "error" } + ); if (!ingestSucceeded) { reportFailure(targetUrl, error, t("list.downloadFailed")); } @@ -1080,6 +1093,7 @@ export default function ArticleListScreen() { maxWidth="sm" sx={{ mt: 2, mx: "auto", display: "flex", flexDirection: "column", alignItems: "center" }} > + {/* Article count and reading time info */} {filteredArticles.length > 0 && ( diff --git a/src/components/ArticleScreen.tsx b/src/components/ArticleScreen.tsx index 383d566..1075768 100644 --- a/src/components/ArticleScreen.tsx +++ b/src/components/ArticleScreen.tsx @@ -55,7 +55,13 @@ import { Route } from "~/routes/article.$slug"; import { useRemoteStorage } from "./RemoteStorageProvider"; import { db } from "~/utils/db"; import { Article, SummaryMeta } from "../../lib/src/models"; -import { removeArticle, patchArticleMetadata, clearFetchFailures } from "~/utils/article/tools"; +import { + removeArticle, + patchArticleMetadata, + clearFetchFailures, + proxyHealth, +} from "~/utils/article/tools"; +import { proxyProblemSummary } from "~/utils/net/proxyHealth"; import { useSnackbar } from "notistack"; import { useTranslation } from "react-i18next"; import ArticleComponent from "./ArticleComponent"; @@ -467,7 +473,15 @@ export default function ArticleScreen(_props: Props) { setRefetchDrawerOpen(false); setRefetchPercent(0); setRefetchStatus(null); - enqueueSnackbar(t("article.refetchFailed"), { variant: "error" }); + const proxyProblem = proxyHealth.getState().problem; + enqueueSnackbar( + proxyProblem + ? t("common.proxyHealth.refetchFailed", { + reason: proxyProblemSummary(t, proxyProblem), + }) + : t("article.refetchFailed"), + { variant: "error" } + ); if (!ingestSucceeded) { reportFailure(targetUrl, error, "Failed to refetch article"); } diff --git a/src/components/DiagnosticsScreen.tsx b/src/components/DiagnosticsScreen.tsx index 1895260..8609a06 100644 --- a/src/components/DiagnosticsScreen.tsx +++ b/src/components/DiagnosticsScreen.tsx @@ -31,6 +31,9 @@ import { environmentConfig, BUILD_TIMESTAMP } from "~/config/environment"; import { isPWAMode } from "~/utils/net/network"; import { getCorsProxyValue, maskCorsProxyInText } from "~/utils/net/corsProxy"; import { useTranslation } from "react-i18next"; +import { useProxyHealth } from "~/hooks/useProxyHealth"; +import { proxyProblemMessage } from "~/utils/net/proxyHealth"; +import { getProxiedRequestsTodayCount } from "~/utils/article/tools"; import React from "react"; interface RemoteStorageEvent { @@ -45,6 +48,7 @@ export default function DiagnosticsScreen() { const articles = useLiveQuery(() => db.articles.toArray()); const { status: syncStatus, isWiFi, isNetworkSupported } = useSyncStatus(); const syncProgress = useSyncProgress(); + const proxyHealthState = useProxyHealth(); const [danglingItems, setDanglingItems] = React.useState< Array<{ path: string; @@ -1041,6 +1045,53 @@ export default function DiagnosticsScreen() { + + + {t("diagnostics.proxyHealthTitle")} + + + {t("diagnostics.proxyHealthDescription")} + + + {proxyHealthState.problem + ? proxyProblemMessage(t, proxyHealthState.problem, proxyHealthState.status) + : t("common.proxyHealth.noProblems")} + + + {maskCorsProxyInText( + JSON.stringify( + { + problem: proxyHealthState.problem, + observedAt: proxyHealthState.observedAt + ? new Date(proxyHealthState.observedAt).toISOString() + : null, + status: proxyHealthState.status ?? null, + lastProbe: proxyHealthState.lastProbe + ? { + ...proxyHealthState.lastProbe, + evidence: { + ...proxyHealthState.lastProbe.evidence, + at: new Date(proxyHealthState.lastProbe.evidence.at).toISOString(), + }, + } + : null, + proxiedRequestsToday: getProxiedRequestsTodayCount(), + }, + null, + 2 + ) + )} + + + {t("diagnostics.buildEnvTitle")} diff --git a/src/components/PreferenceScreen.tsx b/src/components/PreferenceScreen.tsx index fc95e16..6aa0274 100644 --- a/src/components/PreferenceScreen.tsx +++ b/src/components/PreferenceScreen.tsx @@ -28,6 +28,7 @@ import { FormControl, InputAdornment, CircularProgress, + Link, } from "@mui/material"; import { Visibility, VisibilityOff } from "@mui/icons-material"; import { @@ -52,7 +53,15 @@ import { Extension as ExtensionIcon, Language as LanguageIcon, } from "@mui/icons-material"; -import { getStoredCorsProxy, setCorsProxyValue } from "~/utils/net/corsProxy"; +import { + getCorsProxyValue, + getStoredCorsProxy, + SELF_HOST_PROXY_URL, + setCorsProxyValue, +} from "~/utils/net/corsProxy"; +import { proxyProblemMessage } from "~/utils/net/proxyHealth"; +import { getProxiedRequestsTodayCount, resetProxyHealth } from "~/utils/article/tools"; +import { useProxyHealth } from "~/hooks/useProxyHealth"; import { getDefaultCorsProxy, getExtensionUrls, isTelemetryConfigured } from "~/config/environment"; import { hasGlobalPrivacyControl } from "~/utils/telemetry"; // Single source of truth: browser-extension/youtube-transcript.js — the same @@ -218,6 +227,19 @@ export default function PreferencesScreen() { const [language, setLanguage] = React.useState(getAppLanguage); const [corsProxy, setCorsProxy] = React.useState(""); const [isCustomCorsProxy, setIsCustomCorsProxy] = React.useState(false); + const proxyHealthState = useProxyHealth(); + // The effective proxy when the field gained focus, to tell on blur whether + // what was observed about the proxy still applies. + const corsProxyAtFocus = React.useRef(null); + const resetProxyHealthIfChanged = React.useCallback(() => { + // Whatever was observed was about the previous proxy. + if (corsProxyAtFocus.current !== null && corsProxyAtFocus.current !== getCorsProxyValue()) { + resetProxyHealth(); + } + corsProxyAtFocus.current = null; + }, []); + // Leaving the screen mid-edit skips onBlur. + React.useEffect(() => resetProxyHealthIfChanged, [resetProxyHealthIfChanged]); const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); const [syncEnabled, setSyncEnabled] = React.useState(true); const [syncInterval, setSyncInterval] = React.useState(DEFAULT_SYNC_INTERVAL_MS); @@ -448,6 +470,7 @@ export default function PreferencesScreen() { }; const handleCorsProxyFocus = () => { + corsProxyAtFocus.current = getCorsProxyValue(); // When user focuses on the field, show the current value for editing if (!isCustomCorsProxy) { setCorsProxy(getDefaultCorsProxy()); @@ -455,6 +478,7 @@ export default function PreferencesScreen() { }; const handleCorsProxyBlur = () => { + resetProxyHealthIfChanged(); // When user leaves the field, if they haven't entered a custom value, show default greyed out if (!isCustomCorsProxy) { setCorsProxy(getDefaultCorsProxy()); @@ -751,7 +775,7 @@ export default function PreferencesScreen() { )} {showDesktopSaveTools && (extensionUrls.firefoxUrl || extensionUrls.chromeUrl) && ( - + @@ -786,7 +810,7 @@ export default function PreferencesScreen() { )} - + @@ -804,6 +828,30 @@ export default function PreferencesScreen() { {t("prefs.corsProxyHint")} + + {proxyHealthState.problem + ? proxyProblemMessage(t, proxyHealthState.problem, proxyHealthState.status) + : t("common.proxyHealth.noProblems")}{" "} + {t("common.proxyHealth.requestsToday", { + count: getProxiedRequestsTodayCount(), + })} + + {proxyHealthState.problem && !isCustomCorsProxy && ( + + {t("common.proxyHealth.selfHost")} + + )} } /> diff --git a/src/components/ProxyHealthBanner.tsx b/src/components/ProxyHealthBanner.tsx new file mode 100644 index 0000000..9a6a38e --- /dev/null +++ b/src/components/ProxyHealthBanner.tsx @@ -0,0 +1,58 @@ +import { Alert, Box, Link, Typography } from "@mui/material"; +import { Link as RouterLink } from "@tanstack/react-router"; +import { useTranslation } from "react-i18next"; +import { useProxyHealth } from "~/hooks/useProxyHealth"; +import { getProxiedRequestsTodayCount } from "~/utils/article/tools"; +import { getExtensionUrls } from "~/config/environment"; +import { getStoredCorsProxy, SELF_HOST_PROXY_URL } from "~/utils/net/corsProxy"; +import { isPWAMode, isTouchOnlyDevice } from "~/utils/net/network"; +import { proxyProblemMessage } from "~/utils/net/proxyHealth"; + +/** + * Non-blocking notice that the CORS proxy itself has stopped working (#86). + * Says only what was observed; clears when a fetch or probe succeeds. + */ +export default function ProxyHealthBanner() { + const health = useProxyHealth(); + const { t } = useTranslation(); + if (!health.problem) return null; + + const usingDefaultProxy = !getStoredCorsProxy(); + const requestsToday = getProxiedRequestsTodayCount(); + // Same gate as the extension row in Preferences: desktop browsers only. + const extensionUrls = getExtensionUrls(); + const offerExtension = + !isPWAMode() && !isTouchOnlyDevice() && !!(extensionUrls.firefoxUrl || extensionUrls.chromeUrl); + + return ( + + + {proxyProblemMessage(t, health.problem, health.status)} + + + + {t("common.proxyHealth.proxySettings")} + + {usingDefaultProxy && ( + + {t("common.proxyHealth.selfHost")} + + )} + + {offerExtension && ( + + {t("common.proxyHealth.extensionHintBefore")}{" "} + + {t("common.proxyHealth.extensionHintLink")} + {" "} + {t("common.proxyHealth.extensionHintAfter")} + + )} + {requestsToday > 0 && ( + + {t("common.proxyHealth.bannerRequestsToday", { count: requestsToday })} + + )} + + ); +} diff --git a/src/components/SubmitScreen.tsx b/src/components/SubmitScreen.tsx index 7ee6ab5..6d1ee46 100644 --- a/src/components/SubmitScreen.tsx +++ b/src/components/SubmitScreen.tsx @@ -43,6 +43,7 @@ import { } from "~/utils/ai/summarization"; import { db } from "~/utils/db"; import { useRemoteStorage } from "./RemoteStorageProvider"; +import ProxyHealthBanner from "./ProxyHealthBanner"; import { useSnackbar } from "notistack"; import { ingestHtml, ingestPdf, ingestImage } from "lib/src/ingestion"; import { trackCapture } from "~/utils/telemetry"; @@ -482,6 +483,7 @@ export default function SubmitScreen() { {/* Content */} + {t("submit.pasteHint")} diff --git a/src/hooks/useFailureReport.ts b/src/hooks/useFailureReport.ts index 0eb7d7d..af66bb1 100644 --- a/src/hooks/useFailureReport.ts +++ b/src/hooks/useFailureReport.ts @@ -5,18 +5,21 @@ import { isFailureReportConfigured, type FailureReportPayload, } from "~/utils/failureReport"; +import { proxyHealth } from "~/utils/article/tools"; /** * State for the "Report this failed article?" dialog. Call `reportFailure` from * a failed URL ingest catch block; render the dialog when `pending` is set. - * A no-op when the build has no failure-report repo configured. + * A no-op when the build has no failure-report repo configured, and while the + * CORS proxy itself is known to be failing: the article isn't at fault then, + * so a report would only be noise (#86). */ export function useFailureReport() { const [pending, setPending] = useState(null); const reportFailure = useCallback( (url: string, error: unknown, message: string) => { - if (!isFailureReportConfigured()) return; + if (!isFailureReportConfigured() || proxyHealth.getState().problem) return; const detail = errorMessage(error); setPending( buildFailureReportPayload(url, { diff --git a/src/hooks/useProxyHealth.ts b/src/hooks/useProxyHealth.ts new file mode 100644 index 0000000..5f8cf3f --- /dev/null +++ b/src/hooks/useProxyHealth.ts @@ -0,0 +1,8 @@ +import { useSyncExternalStore } from "react"; +import { proxyHealth } from "~/utils/article/tools"; +import type { ProxyHealthState } from "~/utils/net/proxyHealth"; + +/** Live CORS proxy health (#86): problem, when it was seen, last probe. */ +export function useProxyHealth(): ProxyHealthState { + return useSyncExternalStore(proxyHealth.subscribe, proxyHealth.getState); +} diff --git a/src/locales/en/common.json b/src/locales/en/common.json index 1efe2da..eb0d477 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -15,6 +15,24 @@ "pwaUpdate": "Update", "pwaUpdateMessage": "A new version is available", "corsTestPage": "CORS Test Page", - "corsTestIframeTitle": "Savr GitHub Repository" + "corsTestIframeTitle": "Savr GitHub Repository", + "proxyHealth": { + "rateLimited": "Your CORS proxy is rate-limiting requests (HTTP {{status}}).", + "notWorking": "Your CORS proxy is responding but not working. It may have hit a usage limit or be misconfigured.", + "unreachable": "Your CORS proxy isn't responding. Check the URL in Preferences.", + "rateLimitedSummary": "your CORS proxy is rate-limiting requests", + "notWorkingSummary": "your CORS proxy isn't working", + "unreachableSummary": "your CORS proxy isn't responding", + "saveFailed": "Couldn't save: {{reason}}. See the notice above.", + "refetchFailed": "Couldn't refetch: {{reason}}. Check Preferences → CORS Proxy.", + "proxySettings": "Proxy settings", + "selfHost": "Set up your own proxy", + "extensionHintBefore": "Saving with the", + "extensionHintLink": "Savr browser extension", + "extensionHintAfter": "doesn't need the proxy.", + "bannerRequestsToday": "This device sent ~{{count}} requests to the proxy today.", + "noProblems": "No problems observed.", + "requestsToday": "~{{count}} requests from this device today." + } } } diff --git a/src/locales/en/diagnostics.json b/src/locales/en/diagnostics.json index a01ad2b..36b6163 100644 --- a/src/locales/en/diagnostics.json +++ b/src/locales/en/diagnostics.json @@ -78,6 +78,8 @@ "noUrl": "No URL", "scanningOrphaned": "Scanning database for orphaned articles...", "noOrphanedFound": "No orphaned articles found in database.", - "noArticlesFound": "No articles found in the database." + "noArticlesFound": "No articles found in the database.", + "proxyHealthTitle": "CORS Proxy Health", + "proxyHealthDescription": "What Savr observed about the proxy, and why it said so. The probe fetches the proxy prefix with an empty target twice: with CORS (our worker answers with CORS headers, even on errors) and with no-cors (resolves whenever anything answered)." } } diff --git a/src/locales/es/common.json b/src/locales/es/common.json index 91433f3..1669b97 100644 --- a/src/locales/es/common.json +++ b/src/locales/es/common.json @@ -15,6 +15,24 @@ "pwaUpdate": "Actualizar", "pwaUpdateMessage": "Hay una nueva versión disponible", "corsTestPage": "Página de prueba de CORS", - "corsTestIframeTitle": "Repositorio de GitHub de Savr" + "corsTestIframeTitle": "Repositorio de GitHub de Savr", + "proxyHealth": { + "rateLimited": "Tu proxy CORS está limitando las solicitudes (HTTP {{status}}).", + "notWorking": "Tu proxy CORS responde pero no funciona. Puede que haya alcanzado un límite de uso o que esté mal configurado.", + "unreachable": "Tu proxy CORS no responde. Revisa la URL en Preferencias.", + "rateLimitedSummary": "tu proxy CORS está limitando las solicitudes", + "notWorkingSummary": "tu proxy CORS no funciona", + "unreachableSummary": "tu proxy CORS no responde", + "saveFailed": "No se pudo guardar: {{reason}}. Consulta el aviso de arriba.", + "refetchFailed": "No se pudo recargar: {{reason}}. Revisa Preferencias → Proxy CORS.", + "proxySettings": "Ajustes del proxy", + "selfHost": "Configura tu propio proxy", + "extensionHintBefore": "Guardar con la", + "extensionHintLink": "extensión de Savr para el navegador", + "extensionHintAfter": "no necesita el proxy.", + "bannerRequestsToday": "Este dispositivo envió ~{{count}} solicitudes al proxy hoy.", + "noProblems": "No se han detectado problemas.", + "requestsToday": "~{{count}} solicitudes desde este dispositivo hoy." + } } } diff --git a/src/locales/es/diagnostics.json b/src/locales/es/diagnostics.json index 929ec75..e1cb5da 100644 --- a/src/locales/es/diagnostics.json +++ b/src/locales/es/diagnostics.json @@ -78,6 +78,8 @@ "noUrl": "Sin URL", "scanningOrphaned": "Buscando artículos huérfanos en la base de datos...", "noOrphanedFound": "No se encontraron artículos huérfanos en la base de datos.", - "noArticlesFound": "No se encontraron artículos en la base de datos." + "noArticlesFound": "No se encontraron artículos en la base de datos.", + "proxyHealthTitle": "Estado del proxy CORS", + "proxyHealthDescription": "Lo que Savr observó sobre el proxy y por qué lo dijo. La prueba solicita el prefijo del proxy con un destino vacío dos veces: con CORS (nuestro worker responde con cabeceras CORS, incluso en errores) y sin CORS (no-cors, que se resuelve siempre que algo respondió)." } } diff --git a/src/utils/article/tools.ts b/src/utils/article/tools.ts index 30854c3..152736e 100644 --- a/src/utils/article/tools.ts +++ b/src/utils/article/tools.ts @@ -2,12 +2,38 @@ import { Article } from "../../../lib/src/models"; import BaseClient from "remotestoragejs/release/types/baseclient"; import { db } from "../db"; import { deleteArticleStorage, init } from "../sync/storage"; -import { buildProxiedUrl } from "../net/corsProxy"; +import { buildProxiedUrl, getCorsProxyValue, safeLocalStorage } from "../net/corsProxy"; import { getFilePathMetadata, getFilePathThumbnail } from "../../../lib/src/lib"; import { markDirty } from "./publicExport"; import { recordLog, errorMessage } from "../logging"; import { createFetchGovernor, GovernorError } from "../net/fetchGovernor"; import { createDexieOutcomeStore } from "../net/fetchGovernorStore"; +import { + countProxiedRequest, + createProxyHealthMonitor, + getProxiedRequestsToday, + probeProxy, +} from "../net/proxyHealth"; + +/** + * Whether the CORS proxy itself is working (#86). Fed by the governor below; + * the UI subscribes to it for the banner, Preferences and Diagnostics. + */ +export const proxyHealth = createProxyHealthMonitor({ + probe: () => + probeProxy(getCorsProxyValue(), { + fetch: (url, init) => { + countProxiedRequest(safeLocalStorage(), Date.now()); + return fetch(url, init); + }, + isOnline: () => typeof navigator === "undefined" || navigator.onLine !== false, + }), +}); + +/** Requests this device sent to the CORS proxy today (a hint, not a quota). */ +export function getProxiedRequestsTodayCount(): number { + return getProxiedRequestsToday(safeLocalStorage(), Date.now()); +} // One governor for the whole app: all proxied fetches (article pages and every // article image) share the same failure history, so a URL that is failing is @@ -15,6 +41,9 @@ import { createDexieOutcomeStore } from "../net/fetchGovernorStore"; // survives reloads. const fetchGovernor = createFetchGovernor({ store: createDexieOutcomeStore(), + // Direct fetches (no proxy configured) have no proxy to blame: a site's own + // 429 backs off that URL only. + diagnoseProxy: async () => (getCorsProxyValue() ? proxyHealth.diagnose() : false), onEvent: (event) => { if (event.type === "recorded") { recordLog("warn", "fetch", `Fetch failed (${event.class})`, { @@ -25,12 +54,26 @@ const fetchGovernor = createFetchGovernor({ }); } else if (event.type === "breaker-open") { recordLog("warn", "fetch", "CORS proxy circuit breaker opened", { + reason: event.reason, + status: event.status, retryAt: new Date(event.nextAttemptAt).toISOString(), + probe: proxyHealth.getState().lastProbe, }); + } else if (event.type === "breaker-close") { + proxyHealth.reportHealthy(); } }, }); +/** + * Start over after the proxy setting changed: close the breaker and forget + * what was observed about the previous proxy. + */ +export function resetProxyHealth(): void { + fetchGovernor.resetBreaker(); + proxyHealth.reset(); +} + // delete the article from the db and the file system // deleteArticleStorage already handles deleting from both IndexedDB and RemoteStorage export async function removeArticle(slug: string): Promise { @@ -117,7 +160,15 @@ export async function fetchWithTimeout(url: string, timeoutMs: number = 5000): P // window, and short-circuits when the proxy itself is failing. const response = await fetchGovernor.run( { key: url, requestUrl: fetchUrl }, - (requestUrl) => fetch(requestUrl, { signal: AbortSignal.timeout(timeoutMs) }) + async (requestUrl) => { + const proxied = requestUrl !== url; + if (proxied) countProxiedRequest(safeLocalStorage(), Date.now()); + const response = await fetch(requestUrl, { signal: AbortSignal.timeout(timeoutMs) }); + // Any status we can read came back through the proxy with CORS + // headers, so the proxy is working (a 429 is handled by the breaker). + if (proxied && response.status !== 429) proxyHealth.reportHealthy(); + return response; + } ); if (!response.ok) { throw new FetchError(`HTTP ${response.status} ${response.statusText}`.trim(), { diff --git a/src/utils/net/corsProxy.ts b/src/utils/net/corsProxy.ts index 4eeffdb..2eb627e 100644 --- a/src/utils/net/corsProxy.ts +++ b/src/utils/net/corsProxy.ts @@ -20,7 +20,12 @@ const UUID_GLOBAL_RE = new RegExp(UUID_SOURCE, "gi"); export const REDACTED = "[redacted]"; -function storage(): Storage | null { +/** How to deploy a private, token-locked copy of the proxy worker (#84). */ +export const SELF_HOST_PROXY_URL = + "https://github.com/jonocodes/savr/tree/main/infra/cors-worker#deploy-your-own-token-locked"; + +/** localStorage, or null where it is missing or access throws. */ +export function safeLocalStorage(): Storage | null { try { return typeof localStorage !== "undefined" ? localStorage : null; } catch { @@ -53,7 +58,7 @@ function clearLegacyCookie(): void { /** The explicitly configured proxy, or null when the default is in use. */ export function getStoredCorsProxy(): string | null { - const store = storage(); + const store = safeLocalStorage(); if (!store) return readLegacyCookie(); const stored = store.getItem(CORS_PROXY_STORAGE_KEY); @@ -75,7 +80,7 @@ export function getStoredCorsProxy(): string | null { export function setStoredCorsProxy(value: string | null): void { const trimmed = value?.trim() || null; - const store = storage(); + const store = safeLocalStorage(); if (store) { try { if (trimmed) store.setItem(CORS_PROXY_STORAGE_KEY, trimmed); diff --git a/src/utils/net/fetchGovernor.test.ts b/src/utils/net/fetchGovernor.test.ts index d5a12ea..8cd47f6 100644 --- a/src/utils/net/fetchGovernor.test.ts +++ b/src/utils/net/fetchGovernor.test.ts @@ -5,6 +5,7 @@ import { createMemoryOutcomeStore, GovernorError, type BackoffConfig, + type GovernorEvent, type OutcomeStore, } from "./fetchGovernor"; @@ -264,4 +265,191 @@ describe("createFetchGovernor", () => { GovernorError ); }); + + describe("proxy diagnosis on network failures", () => { + const failingAttempt = () => + jest.fn(async (): Promise => { + throw new TypeError("Failed to fetch"); + }); + + it("asks for a diagnosis and opens the breaker when the proxy is at fault", async () => { + const clock = fakeClock(); + const events: GovernorEvent[] = []; + const diagnoseProxy = jest.fn(async () => true); + const governor = createFetchGovernor({ + store, + now: clock.now, + breaker: { baseMs: 60_000 }, + diagnoseProxy, + onEvent: (event) => events.push(event), + }); + + await expect(governor.run({ key: "a", requestUrl: "a" }, failingAttempt())).rejects.toThrow( + "Failed to fetch" + ); + expect(diagnoseProxy).toHaveBeenCalledTimes(1); + expect(events).toContainEqual({ + type: "breaker-open", + reason: "proxy-failure", + nextAttemptAt: clock.now() + 60_000, + }); + + const untouched = jest.fn(async () => ok()); + await expect(governor.run({ key: "b", requestUrl: "b" }, untouched)).rejects.toMatchObject({ + reason: "circuit-open", + }); + expect(untouched).not.toHaveBeenCalled(); + }); + + it("leaves the breaker closed when the proxy is fine (target failure)", async () => { + const clock = fakeClock(); + const governor = createFetchGovernor({ + store, + now: clock.now, + diagnoseProxy: async () => false, + }); + await expect(governor.run({ key: "a", requestUrl: "a" }, failingAttempt())).rejects.toThrow(); + + const next = jest.fn(async () => ok()); + await governor.run({ key: "b", requestUrl: "b" }, next); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("diagnoses a timeout: a hung proxy should read as unreachable", async () => { + const clock = fakeClock(); + const diagnoseProxy = jest.fn(async () => true); + const governor = createFetchGovernor({ store, now: clock.now, diagnoseProxy }); + + const timeout = jest.fn(async (): Promise => { + throw new DOMException("signal timed out", "TimeoutError"); + }); + await expect(governor.run({ key: "t", requestUrl: "t" }, timeout)).rejects.toThrow(); + expect(diagnoseProxy).toHaveBeenCalledTimes(1); + }); + + it("does not diagnose aborts or failures with a visible status", async () => { + const clock = fakeClock(); + const diagnoseProxy = jest.fn(async () => true); + const governor = createFetchGovernor({ store, now: clock.now, diagnoseProxy }); + + const aborted = jest.fn(async (): Promise => { + throw new DOMException("aborted", "AbortError"); + }); + await expect(governor.run({ key: "a", requestUrl: "a" }, aborted)).rejects.toThrow(); + await governor.run( + { key: "s", requestUrl: "s" }, + attemptReturning(new Response("", { status: 502 })) + ); + + expect(diagnoseProxy).not.toHaveBeenCalled(); + }); + + it("treats a failing diagnosis as 'not the proxy' and still rethrows the fetch error", async () => { + const clock = fakeClock(); + const governor = createFetchGovernor({ + store, + now: clock.now, + diagnoseProxy: async () => { + throw new Error("probe exploded"); + }, + }); + await expect(governor.run({ key: "a", requestUrl: "a" }, failingAttempt())).rejects.toThrow( + "Failed to fetch" + ); + const next = jest.fn(async () => ok()); + await governor.run({ key: "b", requestUrl: "b" }, next); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("trips the breaker once for a burst of concurrent failures", async () => { + const clock = fakeClock(); + const events: GovernorEvent[] = []; + const governor = createFetchGovernor({ + store, + now: clock.now, + breaker: { baseMs: 60_000 }, + diagnoseProxy: async () => true, + onEvent: (event) => events.push(event), + }); + + await Promise.allSettled( + ["a", "b", "c"].map((key) => governor.run({ key, requestUrl: key }, failingAttempt())) + ); + + const opens = events.filter((event) => event.type === "breaker-open"); + expect(opens).toHaveLength(1); + expect(opens[0]).toMatchObject({ nextAttemptAt: clock.now() + 60_000 }); + }); + }); + + describe("proxy diagnosis on a visible 429", () => { + const rateLimited = () => attemptReturning(new Response("", { status: 429 })); + + it("opens the breaker when the probe confirms the proxy is rate-limiting", async () => { + const clock = fakeClock(); + const events: GovernorEvent[] = []; + const diagnoseProxy = jest.fn(async () => true); + const governor = createFetchGovernor({ + store, + now: clock.now, + diagnoseProxy, + onEvent: (event) => events.push(event), + }); + + await governor.run({ key: "a", requestUrl: "a" }, rateLimited()); + + expect(diagnoseProxy).toHaveBeenCalledWith({ status: 429 }); + expect(events).toContainEqual( + expect.objectContaining({ type: "breaker-open", reason: "rate-limited", status: 429 }) + ); + }); + + it("backs off only that URL when the 429 came from the target site", async () => { + const clock = fakeClock(); + const events: GovernorEvent[] = []; + const governor = createFetchGovernor({ + store, + now: clock.now, + diagnoseProxy: async () => false, + onEvent: (event) => events.push(event), + }); + + await governor.run({ key: "a", requestUrl: "a" }, rateLimited()); + + expect(events.some((event) => event.type === "breaker-open")).toBe(false); + expect(await store.get("a")).toMatchObject({ class: "transient", status: 429 }); + const other = jest.fn(async () => ok()); + await governor.run({ key: "b", requestUrl: "b" }, other); + expect(other).toHaveBeenCalledTimes(1); + }); + }); + + it("tags a 429 breaker trip as rate-limited", async () => { + const clock = fakeClock(); + const events: GovernorEvent[] = []; + const governor = createFetchGovernor({ store, now: clock.now, onEvent: (e) => events.push(e) }); + await governor.run( + { key: "a", requestUrl: "a" }, + attemptReturning(new Response("", { status: 429 })) + ); + expect(events).toContainEqual( + expect.objectContaining({ type: "breaker-open", reason: "rate-limited", status: 429 }) + ); + }); + + it("resetBreaker() closes an open breaker immediately", async () => { + const clock = fakeClock(); + const events: GovernorEvent[] = []; + const governor = createFetchGovernor({ store, now: clock.now, onEvent: (e) => events.push(e) }); + await governor.run( + { key: "a", requestUrl: "a" }, + attemptReturning(new Response("", { status: 429 })) + ); + + governor.resetBreaker(); + expect(events).toContainEqual({ type: "breaker-close" }); + const next = jest.fn(async () => ok()); + await governor.run({ key: "b", requestUrl: "b" }, next); + expect(next).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/utils/net/fetchGovernor.ts b/src/utils/net/fetchGovernor.ts index 869112e..5c520d5 100644 --- a/src/utils/net/fetchGovernor.ts +++ b/src/utils/net/fetchGovernor.ts @@ -14,8 +14,13 @@ * `nextAttemptAt` passes (permanent failures wait longest) * - backoff: transient failures (timeout/network/5xx/429) retry with * exponential backoff + jitter; permanent failures (404/410/…) do not - * - a circuit breaker: a proxy-level failure (HTTP 429) short-circuits all - * fetches for a cooldown, so a capped proxy cannot be hammered + * - a circuit breaker: a proxy-level failure short-circuits all fetches for + * a cooldown, so a capped proxy cannot be hammered + * - proxy diagnosis: a 429, or a network failure with no visible status (a + * capped Cloudflare worker answers without CORS headers, so the browser + * hides its status), is handed to `diagnoseProxy`; the breaker trips only + * if that blames the proxy, since a proxy passes a target site's own 429 + * through. Without `diagnoseProxy`, every 429 trips it * * The module is intentionally free of browser and Dexie imports so it can be * unit-tested in a bare Node environment; persistence is injected through the @@ -102,9 +107,15 @@ export type GovernorEvent = | { type: "circuit-open"; key: string; nextAttemptAt: number } | { type: "recorded"; key: string; class: FetchOutcome["class"]; status?: number; attempts: number; nextAttemptAt: number } | { type: "cleared"; key: string } - | { type: "breaker-open"; nextAttemptAt: number } + | { type: "breaker-open"; reason: BreakerReason; status?: number; nextAttemptAt: number } | { type: "breaker-close" }; +/** Why the breaker opened: a visible 429, or a diagnosis that blamed the proxy. */ +export type BreakerReason = "rate-limited" | "proxy-failure"; + +/** What made the governor suspect the proxy: a 429, or a status-less error. */ +export type ProxySuspect = { status: 429 } | { error: unknown }; + export interface GovernorOptions { store: OutcomeStore; now?: () => number; @@ -113,6 +124,12 @@ export interface GovernorOptions { breaker?: Partial; permanentTtlMs?: number; onEvent?: (event: GovernorEvent) => void; + /** + * Called on a 429, and when an attempt rejects with no HTTP status + * (timeouts included, deliberate aborts not). Resolve `true` when the proxy + * itself is at fault to open the breaker. + */ + diagnoseProxy?: (failure: ProxySuspect) => Promise; } export interface FetchGovernor { @@ -125,6 +142,8 @@ export interface FetchGovernor { clear(key: string): Promise; /** Forget all failure history (e.g. an explicit "Refetch" of an article). */ clearAll(): Promise; + /** Close the breaker now (e.g. the user changed the proxy setting). */ + resetBreaker(): void; } export const DEFAULT_BACKOFF: BackoffConfig = { @@ -201,8 +220,19 @@ function getErrorStatus(error: unknown): number | undefined { return undefined; } +/** A deliberate cancellation, as opposed to a timeout or network failure. */ +function isAbort(error: unknown): boolean { + return (error as { name?: unknown } | null)?.name === "AbortError"; +} + export function createFetchGovernor(options: GovernorOptions): FetchGovernor { - const { store, now = () => Date.now(), random = Math.random, onEvent } = options; + const { + store, + now = () => Date.now(), + random = Math.random, + onEvent, + diagnoseProxy, + } = options; const backoff: BackoffConfig = { ...DEFAULT_BACKOFF, ...options.backoff }; const breaker: BreakerConfig = { ...DEFAULT_BREAKER, ...options.breaker }; const permanentTtlMs = options.permanentTtlMs ?? DEFAULT_PERMANENT_TTL_MS; @@ -230,11 +260,36 @@ export function createFetchGovernor(options: GovernorOptions): FetchGovernor { return nextAttemptAt; } - function tripBreaker(): void { + function tripBreaker(reason: BreakerReason, status?: number): void { + // A burst of concurrent failures lands here several times; only the first + // one opens the breaker, so the cooldown doesn't escalate in one go. + if (now() < breakerUntil) return; breakerTrips += 1; const cooldown = Math.min(breaker.maxMs, breaker.baseMs * Math.pow(2, breakerTrips - 1)); breakerUntil = now() + cooldown; - emit({ type: "breaker-open", nextAttemptAt: breakerUntil }); + emit({ + type: "breaker-open", + reason, + ...(status !== undefined ? { status } : {}), + nextAttemptAt: breakerUntil, + }); + } + + async function isProxyAtFault(failure: ProxySuspect): Promise { + // Timeouts are diagnosed too: a hung proxy should read as unreachable, and a + // slow target behind a working proxy is cleared by the probe's quick 400. + if ("error" in failure && isAbort(failure.error)) return false; + try { + return (await diagnoseProxy?.(failure)) ?? false; + } catch { + return false; + } + } + + async function onRateLimited(): Promise { + if (!diagnoseProxy || (await isProxyAtFault({ status: 429 }))) { + tripBreaker("rate-limited", 429); + } } function resetBreaker(): void { @@ -289,13 +344,16 @@ export function createFetchGovernor(options: GovernorOptions): FetchGovernor { } const cls = classifyFailureStatus(response.status); await recordFailure(request.key, cls, response.status, previousAttempts); - if (response.status === 429) tripBreaker(); + if (response.status === 429) await onRateLimited(); return response; } catch (error) { const status = getErrorStatus(error); const cls = classifyFailureStatus(status ?? 0); await recordFailure(request.key, cls, status, previousAttempts); - if (status === 429) tripBreaker(); + if (status === 429) await onRateLimited(); + else if (status === undefined && (await isProxyAtFault({ error }))) { + tripBreaker("proxy-failure"); + } throw error; } finally { inflight.delete(request.key); @@ -314,5 +372,5 @@ export function createFetchGovernor(options: GovernorOptions): FetchGovernor { await store.clear?.(); } - return { run, clear, clearAll }; + return { run, clear, clearAll, resetBreaker }; } diff --git a/src/utils/net/proxyHealth.test.ts b/src/utils/net/proxyHealth.test.ts new file mode 100644 index 0000000..787bac0 --- /dev/null +++ b/src/utils/net/proxyHealth.test.ts @@ -0,0 +1,339 @@ +import { + classifyProbe, + countProxiedRequest, + createProxyHealthMonitor, + getProxiedRequestsToday, + probeProxy, + proxyProblemMessage, + proxyProblemSummary, + type ProbeEvidence, +} from "./proxyHealth"; +import i18next, { setAppLanguage } from "~/i18n"; + +const PROXY = "https://proxy.example/?url="; + +function fakeClock(start = 1_000_000) { + let t = start; + return { + now: () => t, + advance: (ms: number) => { + t += ms; + }, + }; +} + +/** + * A fetch stub that answers the CORS and no-cors probes separately. `reject` + * models the browser's bare network TypeError. + */ +function probeFetch(cors: number | "reject", noCors: "resolve" | "reject") { + return jest.fn(async (_url: string, init?: RequestInit) => { + const outcome = init?.mode === "no-cors" ? noCors : cors; + if (outcome === "reject") throw new TypeError("Failed to fetch"); + if (outcome === "resolve") return new Response(null, { status: 200 }); + return new Response("Missing 'url' parameter", { status: outcome }); + }); +} + +function evidence(partial: Partial): ProbeEvidence { + return { at: 0, online: true, ...partial }; +} + +describe("probeProxy", () => { + it("probes the proxy prefix itself (empty target) both with and without CORS", async () => { + const fetchImpl = probeFetch(400, "resolve"); + await probeProxy(PROXY, { fetch: fetchImpl, isOnline: () => true }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + for (const [url] of fetchImpl.mock.calls) expect(url).toBe(PROXY); + const modes = fetchImpl.mock.calls.map(([, init]) => init?.mode ?? "cors"); + expect(modes.sort()).toEqual(["cors", "no-cors"]); + }); + + it("records the CORS status and both outcomes as evidence", async () => { + const clock = fakeClock(42); + const result = await probeProxy(PROXY, { + fetch: probeFetch(400, "resolve"), + isOnline: () => true, + now: clock.now, + }); + expect(result).toEqual({ + at: 42, + online: true, + cors: { ok: true, status: 400 }, + noCors: { ok: true }, + }); + }); + + it("records rejections with their message", async () => { + const result = await probeProxy(PROXY, { + fetch: probeFetch("reject", "reject"), + isOnline: () => true, + }); + expect(result.cors).toEqual({ ok: false, error: "TypeError: Failed to fetch" }); + expect(result.noCors).toEqual({ ok: false, error: "TypeError: Failed to fetch" }); + }); + + it("does not touch the network while offline", async () => { + const fetchImpl = probeFetch(400, "resolve"); + const result = await probeProxy(PROXY, { fetch: fetchImpl, isOnline: () => false }); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(result.online).toBe(false); + expect(classifyProbe(result)).toBe("offline"); + }); +}); + +describe("classifyProbe", () => { + it("CORS-reject + no-cors-resolve → responding but not working", () => { + expect( + classifyProbe(evidence({ cors: { ok: false, error: "x" }, noCors: { ok: true } })) + ).toBe("not-working"); + }); + + it("both reject while online → unreachable", () => { + expect( + classifyProbe(evidence({ cors: { ok: false, error: "x" }, noCors: { ok: false, error: "x" } })) + ).toBe("unreachable"); + }); + + it("offline → no proxy verdict", () => { + expect(classifyProbe(evidence({ online: false }))).toBe("offline"); + }); + + it("a CORS 429 → the proxy itself is rate-limiting", () => { + expect(classifyProbe(evidence({ cors: { ok: true, status: 429 }, noCors: { ok: true } }))).toBe( + "rate-limited" + ); + }); + + it.each([200, 400, 404, 500])("any other CORS response (HTTP %i) → running", (status) => { + expect(classifyProbe(evidence({ cors: { ok: true, status }, noCors: { ok: true } }))).toBe( + "running" + ); + }); +}); + +describe("createProxyHealthMonitor", () => { + function setup(fetchImpl: ReturnType, minProbeIntervalMs = 60_000) { + const clock = fakeClock(); + const monitor = createProxyHealthMonitor({ + probe: () => probeProxy(PROXY, { fetch: fetchImpl, isOnline: () => true, now: clock.now }), + now: clock.now, + minProbeIntervalMs, + }); + return { clock, monitor }; + } + + it("starts healthy", () => { + const { monitor } = setup(probeFetch(400, "resolve")); + expect(monitor.getState().problem).toBeNull(); + }); + + it("blames the proxy when it answers without CORS headers", async () => { + const { monitor } = setup(probeFetch("reject", "resolve")); + await expect(monitor.diagnose()).resolves.toBe(true); + expect(monitor.getState().problem).toBe("not-working"); + expect(monitor.getState().lastProbe?.verdict).toBe("not-working"); + }); + + it("blames the proxy when it does not answer at all", async () => { + const { monitor } = setup(probeFetch("reject", "reject")); + await expect(monitor.diagnose()).resolves.toBe(true); + expect(monitor.getState().problem).toBe("unreachable"); + }); + + it("does not blame the proxy for a single bad target host", async () => { + const { monitor } = setup(probeFetch(400, "resolve")); + await expect(monitor.diagnose()).resolves.toBe(false); + expect(monitor.getState().problem).toBeNull(); + expect(monitor.getState().lastProbe?.verdict).toBe("running"); + }); + + it("gives no proxy verdict while offline", async () => { + const clock = fakeClock(); + const fetchImpl = probeFetch("reject", "reject"); + const monitor = createProxyHealthMonitor({ + probe: () => probeProxy(PROXY, { fetch: fetchImpl, isOnline: () => false, now: clock.now }), + now: clock.now, + }); + await expect(monitor.diagnose()).resolves.toBe(false); + expect(monitor.getState().problem).toBeNull(); + }); + + it("coalesces concurrent diagnoses into one probe", async () => { + const fetchImpl = probeFetch("reject", "resolve"); + const { monitor } = setup(fetchImpl); + const results = await Promise.all([monitor.diagnose(), monitor.diagnose(), monitor.diagnose()]); + expect(results).toEqual([true, true, true]); + expect(fetchImpl).toHaveBeenCalledTimes(2); // one probe = CORS + no-cors + }); + + it("throttles probes, reusing the last verdict inside the window", async () => { + const fetchImpl = probeFetch(400, "resolve"); + const { monitor, clock } = setup(fetchImpl, 60_000); + await monitor.diagnose(); + clock.advance(59_000); + await expect(monitor.diagnose()).resolves.toBe(false); + expect(fetchImpl).toHaveBeenCalledTimes(2); + + clock.advance(2_000); + await monitor.diagnose(); + expect(fetchImpl).toHaveBeenCalledTimes(4); + }); + + it("a successful probe clears an earlier problem", async () => { + let healthy = false; + const clock = fakeClock(); + const monitor = createProxyHealthMonitor({ + probe: () => + probeProxy(PROXY, { + fetch: healthy ? probeFetch(400, "resolve") : probeFetch("reject", "resolve"), + isOnline: () => true, + now: clock.now, + }), + now: clock.now, + minProbeIntervalMs: 1_000, + }); + await monitor.diagnose(); + expect(monitor.getState().problem).toBe("not-working"); + + healthy = true; + clock.advance(1_000); + await monitor.diagnose(); + expect(monitor.getState().problem).toBeNull(); + }); + + it("blames the proxy when the probe itself is rate-limited", async () => { + const { monitor, clock } = setup(probeFetch(429, "resolve")); + await expect(monitor.diagnose()).resolves.toBe(true); + expect(monitor.getState()).toMatchObject({ + problem: "rate-limited", + status: 429, + observedAt: clock.now(), + }); + }); + + it("does not blame the proxy for a target's 429 when the probe gets its 400", async () => { + const { monitor } = setup(probeFetch(400, "resolve")); + await expect(monitor.diagnose()).resolves.toBe(false); + expect(monitor.getState().problem).toBeNull(); + }); + + it("reportHealthy() clears the problem (a response came through the proxy)", async () => { + const { monitor } = setup(probeFetch(429, "resolve")); + await monitor.diagnose(); + monitor.reportHealthy(); + expect(monitor.getState().problem).toBeNull(); + }); + + it("keeps the first observation time while the problem persists", async () => { + const { monitor, clock } = setup(probeFetch("reject", "resolve"), 1_000); + await monitor.diagnose(); + const first = monitor.getState().observedAt; + clock.advance(5_000); + await monitor.diagnose(); + expect(monitor.getState().observedAt).toBe(first); + }); + + it("reset() forgets the problem and the throttle", async () => { + const fetchImpl = probeFetch("reject", "resolve"); + const { monitor } = setup(fetchImpl); + await monitor.diagnose(); + monitor.reset(); + expect(monitor.getState().problem).toBeNull(); + expect(monitor.getState().lastProbe).toBeUndefined(); + await monitor.diagnose(); + expect(fetchImpl).toHaveBeenCalledTimes(4); + }); + + it("notifies subscribers on change, and stops after unsubscribe", async () => { + const { monitor } = setup(probeFetch("reject", "resolve")); + const listener = jest.fn(); + const unsubscribe = monitor.subscribe(listener); + await monitor.diagnose(); + expect(listener).toHaveBeenCalledTimes(1); + unsubscribe(); + monitor.reportHealthy(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("returns a stable snapshot until something changes", async () => { + const { monitor } = setup(probeFetch("reject", "resolve")); + const a = monitor.getState(); + expect(monitor.getState()).toBe(a); + monitor.reportHealthy(); // already healthy: no change + expect(monitor.getState()).toBe(a); + await monitor.diagnose(); + expect(monitor.getState()).not.toBe(a); + }); +}); + +describe("proxy problem copy", () => { + afterEach(async () => { + await setAppLanguage("en"); + }); + + it("says only what was observed", () => { + const t = i18next.t.bind(i18next); + expect(proxyProblemMessage(t, "rate-limited", 429)).toBe( + "Your CORS proxy is rate-limiting requests (HTTP 429)." + ); + expect(proxyProblemMessage(t, "not-working")).toBe( + "Your CORS proxy is responding but not working. It may have hit a usage limit or be misconfigured." + ); + expect(proxyProblemMessage(t, "unreachable")).toBe( + "Your CORS proxy isn't responding. Check the URL in Preferences." + ); + }); + + it("has a short clause for save-failure notices", () => { + const t = i18next.t.bind(i18next); + expect(proxyProblemSummary(t, "rate-limited")).toBe("your CORS proxy is rate-limiting requests"); + expect(proxyProblemSummary(t, "not-working")).toBe("your CORS proxy isn't working"); + expect(proxyProblemSummary(t, "unreachable")).toBe("your CORS proxy isn't responding"); + }); + + it("is translated", async () => { + await setAppLanguage("es"); + const t = i18next.t.bind(i18next); + expect(proxyProblemMessage(t, "rate-limited", 429)).toBe( + "Tu proxy CORS está limitando las solicitudes (HTTP 429)." + ); + expect(proxyProblemSummary(t, "unreachable")).toBe("tu proxy CORS no responde"); + }); +}); + +describe("proxied request counter", () => { + function memoryStorage(): Pick { + const map = new Map(); + return { + getItem: (key) => map.get(key) ?? null, + setItem: (key, value) => { + map.set(key, value); + }, + }; + } + + it("counts requests per local day and restarts on a new day", () => { + const store = memoryStorage(); + const day1 = new Date(2026, 9, 15, 10).getTime(); + const day2 = new Date(2026, 9, 16, 9).getTime(); + + expect(getProxiedRequestsToday(store, day1)).toBe(0); + countProxiedRequest(store, day1); + countProxiedRequest(store, day1, 2); + expect(getProxiedRequestsToday(store, day1)).toBe(3); + + expect(getProxiedRequestsToday(store, day2)).toBe(0); + countProxiedRequest(store, day2); + expect(getProxiedRequestsToday(store, day2)).toBe(1); + }); + + it("tolerates corrupt or missing storage", () => { + const store = memoryStorage(); + store.setItem("savr-proxy-requests", "not json"); + expect(getProxiedRequestsToday(store, 0)).toBe(0); + expect(getProxiedRequestsToday(null, 0)).toBe(0); + expect(() => countProxiedRequest(null, 0)).not.toThrow(); + }); +}); diff --git a/src/utils/net/proxyHealth.ts b/src/utils/net/proxyHealth.ts new file mode 100644 index 0000000..75304fa --- /dev/null +++ b/src/utils/net/proxyHealth.ts @@ -0,0 +1,269 @@ +/** + * CORS proxy health — notice when the proxy itself has stopped working and + * say what was observed (#86). + * + * The free Cloudflare plan caps a worker at 100k requests/day; past that the + * edge answers with its own error page (1027) instead of running our worker. + * That page has no `Access-Control-Allow-Origin` header, so the browser + * reports a bare network `TypeError` and the app never sees a status — the + * governor's 429 breaker can't trip on it. + * + * Our worker adds CORS headers to every response it produces, errors included. + * So when a proxied fetch fails at the network level we probe the proxy prefix + * itself (empty target, e.g. `https://…/?url=`), which our worker answers with + * a CORS 400: + * + * - CORS fetch answers 429 → the proxy is rate-limiting + * - CORS fetch resolves (any other status) → running; the target failed + * - CORS rejects, `no-cors` fetch resolves → something answered that is + * not our code: likely exhausted, or the worker crashed + * - both reject while online → unreachable + * - offline → no verdict + * + * A visible 429 is probed the same way: the worker passes a target's own + * status through, so only the probe can tell "the proxy is rate-limiting" + * (probe gets 429) from "this site is" (probe gets the usual 400). + * + * Pure: fetch, clock and storage are injected so this runs in bare Node. + */ + +export type ProbeOutcome = { ok: true; status?: number } | { ok: false; error: string }; + +export interface ProbeEvidence { + /** When the probe ran (epoch ms). */ + at: number; + /** `navigator.onLine` at probe time; offline probes skip the network. */ + online: boolean; + /** Normal CORS fetch of the proxy prefix. */ + cors?: ProbeOutcome; + /** `mode: "no-cors"` fetch: resolves whenever the server answered at all. */ + noCors?: ProbeOutcome; +} + +export type ProbeVerdict = "running" | "rate-limited" | "not-working" | "unreachable" | "offline"; + +export type ProxyProblem = "rate-limited" | "not-working" | "unreachable"; + +export interface ProxyHealthState { + problem: ProxyProblem | null; + /** When the current problem was first observed (epoch ms). */ + observedAt?: number; + /** HTTP status behind the problem, when one was visible (429). */ + status?: number; + /** The most recent probe, kept as raw evidence for Diagnostics. */ + lastProbe?: { evidence: ProbeEvidence; verdict: ProbeVerdict }; +} + +export interface ProbeDeps { + fetch: (url: string, init?: RequestInit) => Promise; + isOnline: () => boolean; + now?: () => number; + timeoutMs?: number; +} + +function describeError(error: unknown): string { + if (error instanceof Error) return `${error.name}: ${error.message}`; + return String(error); +} + +async function attemptProbe( + url: string, + init: RequestInit, + deps: ProbeDeps +): Promise { + try { + const response = await deps.fetch(url, { + ...init, + cache: "no-store", + signal: AbortSignal.timeout(deps.timeoutMs ?? 5000), + }); + // An opaque no-cors response always reports status 0; leave it out. + return init.mode === "no-cors" ? { ok: true } : { ok: true, status: response.status }; + } catch (error) { + return { ok: false, error: describeError(error) }; + } +} + +/** Probe `proxy` (the configured prefix, target left empty) two ways. */ +export async function probeProxy(proxy: string, deps: ProbeDeps): Promise { + const at = (deps.now ?? Date.now)(); + if (!deps.isOnline()) return { at, online: false }; + const [cors, noCors] = await Promise.all([ + attemptProbe(proxy, {}, deps), + attemptProbe(proxy, { mode: "no-cors" }, deps), + ]); + return { at, online: true, cors, noCors }; +} + +export function classifyProbe(evidence: ProbeEvidence): ProbeVerdict { + if (!evidence.online) return "offline"; + // Any response carrying CORS headers (400, 404, …) means our code answered. + // A token-locked proxy's 404 for a bad token lands here too: running, but + // misconfigured — the per-URL statuses already show that. + if (evidence.cors?.ok) return evidence.cors.status === 429 ? "rate-limited" : "running"; + return evidence.noCors?.ok ? "not-working" : "unreachable"; +} + +export interface ProxyHealthMonitorOptions { + probe: () => Promise; + now?: () => number; + /** Minimum gap between probes; inside it the last verdict is reused. */ + minProbeIntervalMs?: number; +} + +export interface ProxyHealthMonitor { + getState(): ProxyHealthState; + subscribe(listener: () => void): () => void; + /** + * A proxied fetch failed with no visible status, or with a 429. Probe + * (throttled and coalesced) and resolve `true` when the proxy itself is at + * fault. + */ + diagnose(): Promise; + /** A response came back through the proxy / the breaker closed. */ + reportHealthy(): void; + /** Forget everything, e.g. after the proxy setting changed. */ + reset(): void; +} + +/** Matches the governor's base breaker cooldown: one probe per breaker cycle. */ +export const DEFAULT_MIN_PROBE_INTERVAL_MS = 60_000; + +export function createProxyHealthMonitor(options: ProxyHealthMonitorOptions): ProxyHealthMonitor { + const { probe, now = () => Date.now() } = options; + const minProbeIntervalMs = options.minProbeIntervalMs ?? DEFAULT_MIN_PROBE_INTERVAL_MS; + const listeners = new Set<() => void>(); + let state: ProxyHealthState = { problem: null }; + let inflight: Promise | null = null; + // Bumped by reset() so a probe that was in flight can't write stale state. + let generation = 0; + + function setState(next: ProxyHealthState): void { + state = next; + for (const listener of listeners) listener(); + } + + function withProblem(problem: ProxyProblem | null, status?: number): ProxyHealthState { + if (problem === null) return { problem: null, lastProbe: state.lastProbe }; + return { + problem, + observedAt: state.problem === problem ? state.observedAt : now(), + status, + lastProbe: state.lastProbe, + }; + } + + function isBlamed(verdict: ProbeVerdict | undefined): boolean { + return verdict === "rate-limited" || verdict === "not-working" || verdict === "unreachable"; + } + + async function runProbe(): Promise { + const started = generation; + const evidence = await probe(); + if (started !== generation) return false; + const verdict = classifyProbe(evidence); + const lastProbe = { evidence, verdict }; + if (verdict === "offline") { + setState({ ...state, lastProbe }); + } else if (verdict === "running") { + setState({ problem: null, lastProbe }); + } else { + const status = verdict === "rate-limited" ? 429 : undefined; + setState({ ...withProblem(verdict, status), lastProbe }); + } + return isBlamed(verdict); + } + + function diagnose(): Promise { + if (inflight) return inflight; + const last = state.lastProbe; + if (last && last.verdict !== "offline" && now() - last.evidence.at < minProbeIntervalMs) { + return Promise.resolve(isBlamed(last.verdict)); + } + inflight = runProbe().finally(() => { + inflight = null; + }); + return inflight; + } + + return { + getState: () => state, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + diagnose, + reportHealthy() { + if (state.problem !== null) setState(withProblem(null)); + }, + reset() { + generation += 1; + inflight = null; + if (state.problem !== null || state.lastProbe) setState({ problem: null }); + }, + }; +} + +/** + * The app's translate function (i18next `t`), injected so this module stays + * free of i18n setup. Keys live under `common.proxyHealth` in src/locales. + */ +export type Translate = (key: string, options?: { status?: number }) => string; + +/** User-facing copy: what was observed, cause hedged, no reset times. */ +export function proxyProblemMessage(t: Translate, problem: ProxyProblem, status = 429): string { + switch (problem) { + case "rate-limited": + return t("common.proxyHealth.rateLimited", { status }); + case "not-working": + return t("common.proxyHealth.notWorking"); + case "unreachable": + return t("common.proxyHealth.unreachable"); + } +} + +/** The same observation as a clause, for "Couldn't save: …" notices. */ +export function proxyProblemSummary(t: Translate, problem: ProxyProblem): string { + switch (problem) { + case "rate-limited": + return t("common.proxyHealth.rateLimitedSummary"); + case "not-working": + return t("common.proxyHealth.notWorkingSummary"); + case "unreachable": + return t("common.proxyHealth.unreachableSummary"); + } +} + +/** + * Per-device count of requests sent to the proxy today — only a hint next to + * the message, since other devices share the same proxy. + */ +export const PROXY_REQUEST_COUNT_KEY = "savr-proxy-requests"; + +type CounterStorage = Pick; + +function localDay(timestamp: number): string { + const d = new Date(timestamp); + return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`; +} + +export function getProxiedRequestsToday(storage: CounterStorage | null, now: number): number { + if (!storage) return 0; + try { + const parsed = JSON.parse(storage.getItem(PROXY_REQUEST_COUNT_KEY) ?? "null"); + if (parsed?.day === localDay(now) && typeof parsed.count === "number") return parsed.count; + } catch { + // Corrupt value: start over. + } + return 0; +} + +export function countProxiedRequest(storage: CounterStorage | null, now: number, by = 1): void { + if (!storage) return; + const count = getProxiedRequestsToday(storage, now) + by; + try { + storage.setItem(PROXY_REQUEST_COUNT_KEY, JSON.stringify({ day: localDay(now), count })); + } catch { + // Storage full or unavailable: the count is only a hint. + } +} diff --git a/tests/e2e/proxy-health.spec.ts b/tests/e2e/proxy-health.spec.ts new file mode 100644 index 0000000..85ff10e --- /dev/null +++ b/tests/e2e/proxy-health.spec.ts @@ -0,0 +1,154 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { test, expect, type Page, type Route } from "@playwright/test"; + +// A proxy that only exists inside page.route(): each test decides how it +// answers. `.invalid` never resolves, so nothing leaks to a real network. +const PROXY_ORIGIN = "https://proxy.savr-test.invalid"; +const TARGET = "https://example.com/some-article"; + +async function openWithProxy(page: Page, proxy: string) { + await page.addInitScript((value) => { + localStorage.setItem("savr-cors-proxy", value); + }, proxy); + await page.goto("/"); + await page.waitForLoadState("networkidle"); +} + +async function useRoutedProxy(page: Page, handler: (route: Route) => Promise) { + await page.route(`${PROXY_ORIGIN}/**`, handler); + await openWithProxy(page, `${PROXY_ORIGIN}/?url=`); +} + +async function saveUrl(page: Page) { + await page.locator('button[aria-label*="add" i]').first().click(); + const addDialog = page.getByRole("dialog", { name: "Add Article" }); + await expect(addDialog).toBeVisible(); + await addDialog.getByLabel("URL").fill(TARGET); + await addDialog.getByRole("button", { name: "Save" }).click(); +} + +test.describe("CORS proxy health banner", () => { + // Stands in for Cloudflare's edge once a free worker is capped (error 1027): + // its own page, without our worker's CORS headers. This needs a real server: + // route.fulfill() adds Access-Control-Allow-Origin by itself, so the browser + // would never see a CORS failure. + let cappedEdge: Server; + let cappedEdgeUrl: string; + + test.beforeAll(async () => { + cappedEdge = createServer((_req, res) => { + res.writeHead(429, { "Content-Type": "text/plain" }); + res.end("error code: 1027"); + }); + await new Promise((resolve) => cappedEdge.listen(0, "127.0.0.1", resolve)); + cappedEdgeUrl = `http://127.0.0.1:${(cappedEdge.address() as AddressInfo).port}`; + }); + + test.afterAll(async () => { + await new Promise((resolve) => cappedEdge.close(resolve)); + }); + + test("a visible 429 reads as rate-limiting", async ({ page }) => { + await useRoutedProxy(page, (route) => + route.fulfill({ + status: 429, + headers: { "Access-Control-Allow-Origin": "*" }, + body: "Too Many Requests", + }) + ); + await saveUrl(page); + + const banner = page.getByTestId("proxy-health-banner"); + await expect(banner).toBeVisible({ timeout: 15000 }); + await expect(banner.getByTestId("proxy-health-message")).toHaveText( + "Your CORS proxy is rate-limiting requests (HTTP 429)." + ); + }); + + test("an answer without CORS headers reads as responding but not working", async ({ page }) => { + await openWithProxy(page, `${cappedEdgeUrl}/?url=`); + await saveUrl(page); + + const banner = page.getByTestId("proxy-health-banner"); + await expect(banner).toBeVisible({ timeout: 15000 }); + await expect(banner.getByTestId("proxy-health-message")).toHaveText( + "Your CORS proxy is responding but not working. It may have hit a usage limit or be misconfigured." + ); + // A custom proxy is configured, so no "set up your own" pitch. + await expect(banner.getByRole("link", { name: "Set up your own proxy" })).toHaveCount(0); + + // Desktop browsers are pointed at the extension, which needs no proxy. + await expect(banner.getByTestId("proxy-health-extension-hint")).toBeVisible(); + + // The save failure says why, and doesn't ask to report the article: the + // article isn't at fault. + await expect( + page.getByText("Couldn't save: your CORS proxy isn't working. See the notice above.") + ).toBeVisible(); + await expect(page.getByTestId("failure-report-dialog")).toHaveCount(0); + + // The same observation shows next to the setting in Preferences. + await banner.getByRole("link", { name: "Proxy settings" }).click(); + await expect(page.getByTestId("proxy-health-status")).toContainText( + "Your CORS proxy is responding but not working." + ); + }); + + test("a target site's own 429 behind a working proxy shows no banner", async ({ page }) => { + await useRoutedProxy(page, (route) => { + const target = new URL(route.request().url()).searchParams.get("url"); + // The worker passes the target's 429 through, CORS headers and all... + if (target) { + return route.fulfill({ + status: 429, + headers: { "Access-Control-Allow-Origin": "*" }, + body: "Too Many Requests", + }); + } + // ...but answers the probe itself with its usual 400. + return route.fulfill({ + status: 400, + headers: { "Access-Control-Allow-Origin": "*" }, + body: "Missing 'url' parameter", + }); + }); + await saveUrl(page); + + // The usual failed-capture flow: this article really did fail. + await expect(page.getByTestId("failure-report-dialog")).toBeVisible({ timeout: 15000 }); + await expect(page.getByText(/Error saving article/)).toBeVisible(); + await expect(page.getByTestId("proxy-health-banner")).toHaveCount(0); + }); + + test("no answer at all reads as not responding", async ({ page }) => { + await useRoutedProxy(page, (route) => route.abort("connectionrefused")); + await saveUrl(page); + + const banner = page.getByTestId("proxy-health-banner"); + await expect(banner).toBeVisible({ timeout: 15000 }); + await expect(banner.getByTestId("proxy-health-message")).toHaveText( + "Your CORS proxy isn't responding. Check the URL in Preferences." + ); + }); + + test("a failing target behind a working proxy shows no banner", async ({ page }) => { + let probes = 0; + await useRoutedProxy(page, (route) => { + const target = new URL(route.request().url()).searchParams.get("url"); + if (target) return route.abort("connectionrefused"); + // The probe (empty target): our worker's CORS 400 means "running". + probes += 1; + return route.fulfill({ + status: 400, + headers: { "Access-Control-Allow-Origin": "*" }, + body: "Missing 'url' parameter", + }); + }); + await saveUrl(page); + + await expect(page.getByText(/Error saving article/)).toBeVisible({ timeout: 15000 }); + expect(probes).toBeGreaterThan(0); + await expect(page.getByTestId("proxy-health-banner")).toHaveCount(0); + }); +});