Skip to content

Tell the user when their CORS proxy stops working (#86) - #119

Merged
jonocodes merged 5 commits into
mainfrom
feature/implement-86
Oct 16, 2026
Merged

jonocodes merged 5 commits into
mainfrom
feature/implement-86

Conversation

@jonocodes

@jonocodes jonocodes commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Closes #86.

Problem

When the CORS proxy is maxed out (Cloudflare free plan, error 1027), saves and images fail with no explanation. The 1027 page comes from Cloudflare's edge, not our worker, so it has no CORS headers. The browser then reports a bare TypeError with no status, and the governor's 429 breaker never tripped.

What this does

Detection (src/utils/net/proxyHealth.ts, pure and unit-tested):

  • When a proxied fetch fails at the network level (no visible status, timeouts included) or gets a 429, the governor asks a health monitor whether the proxy is at fault.
  • The monitor probes the configured prefix with an empty target, once with CORS and once with no-cors. Our worker answers that probe with a CORS 400, for public, path-token and query-token prefixes, and also behind the Restrict the public CORS proxy to allowlisted calling sites #118 origin allowlist.
  • How the probe results are read:
Probe result Verdict
CORS request gets 429 Rate-limited (the proxy itself)
CORS request gets any other answer Proxy running; the target failed. Only that URL backs off, no banner
Only the no-cors request gets an answer Responding but not working: likely capped, or the worker crashed
Neither gets an answer while online Unreachable
Offline No verdict
  • The 429 is probed because the worker passes a target site's own 429 straight through. Before this PR, any 429 paused every proxied request.
  • A bad verdict trips the existing breaker. A burst of failures trips it once rather than escalating the cooldown.
  • Probes are shared between simultaneous failures and run at most once a minute.

UX: says only what was observed, with the issue's exact copy and no reset times.

  • A non-blocking banner on the article list and the add screen. It links to Preferences → CORS Proxy and, on the shared default proxy, to "Set up your own proxy".

  • On desktop, the banner also notes that saving with the browser extension needs no proxy.

  • While the proxy is failing, a failed save or refetch says so ("Couldn't save: your CORS proxy isn't working. See the notice above.") instead of offering "Report this failed article?". The article isn't at fault, so a report would only be noise.

  • A status line in Preferences and in Diagnostics.

  • Diagnostics also shows the raw probe evidence and a per-device "proxy requests today" count. The proxy token stays masked.

  • The banner clears as soon as any response comes back through the proxy or a probe succeeds. Changing the proxy setting resets it.

  • All new copy is translated (English and Spanish) via i18next, under common.proxyHealth and diagnostics.*.

There is no automatic switching or fallback, as the issue's non-goal requires.

Tests

  • Unit:

    • probe classifier, including a CORS 429 meaning rate-limited;
    • monitor: throttle, coalescing, offline, clears on success, reset, subscriptions;
    • governor diagnosis: proxy at fault or not, on network failures and on 429s; timeouts vs aborts; a burst trips once; 429 without diagnosis; resetBreaker.
  • Worker: the empty-target probe gets a CORS 400 for every prefix shape, and behind the origin allowlist.

  • Playwright (tests/e2e/proxy-health.spec.ts):

    • the proxy's own 429;
    • a target's 429 behind a working proxy: no banner, and the report dialog is still offered;
    • a capped edge: banner, "Couldn't save" notice, no report dialog, extension hint, Preferences status;
    • unreachable;
    • a network-failing target behind a working proxy: no banner.

    route.fulfill() adds Access-Control-Allow-Origin by itself, so the capped-edge case uses a real local server that answers 429 error code: 1027 with no CORS headers.

  • Results (after rebasing onto main with i18n): Jest 472 passed, node --test for the worker/stats 63 passed, tsc clean. Full e2e: 100 passed and 2 failed. The two failures are multi-browser/natural sync specs that configure no proxy, so they never reach this code, and they passed when re-run alone (4/4). They've been flaky under full-suite load on this branch before.

Still open

🤖 Generated with Claude Code

Screenshots

Capped proxy (stand-in 1027 answer, no CORS headers): banner on the article list, and the failed save names the proxy instead of offering a failure report

screenshot 1

The same observation as a status line under Preferences → CORS Proxy

screenshot 2

@netlify

netlify Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for savrlist ready!

Name Link
🔨 Latest commit 6e8b1a9
🔍 Latest deploy log https://app.netlify.com/projects/savrlist/deploys/6ad26830f04627f11adf5354
😎 Deploy Preview https://deploy-preview-119--savrlist.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

jonocodes and others added 5 commits October 16, 2026 10:57
A capped Cloudflare worker (error 1027) is answered by the edge, not our
code, so the response has no CORS headers: the browser reports a bare
TypeError and the governor's 429 breaker never tripped.

On a network-level failure of a proxied fetch, the governor now asks a
proxy-health monitor to probe the configured prefix with an empty target,
once with CORS and once with no-cors. Our worker answers that with a CORS
400, so "CORS fails, no-cors succeeds" means reachable-but-not-our-code and
"both fail while online" means unreachable. A bad verdict trips the
existing breaker (one trip per burst); probes are coalesced and throttled
to one per minute; offline gives no verdict.

The app shows what it observed — a non-blocking banner on the list and
add screens, a status line under Preferences → CORS Proxy, and the raw
probe evidence plus a per-device daily request count in Diagnostics. It
clears when a request or probe succeeds, and resets when the proxy
setting changes. No automatic switching, no reset-time guesses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Diagnose timeouts too: a hung proxy now reads as unreachable (a slow
  target behind a working proxy is cleared by the probe's quick 400).
  Deliberate aborts are still skipped.
- Any readable response through the proxy (not just a 2xx) proves it
  works, so it clears the banner.
- Count only probe requests actually sent (none while offline).
- Reset proxy health when the setting changed, on blur/unmount rather than
  on every keystroke.
- Plain-language status line in Diagnostics, not just the raw evidence.
- SELF_HOST_PROXY_URL and the localStorage helper live in corsProxy.ts.
- DEVELOPMENT.md: governor diagnosis + a Proxy health section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The public proxy now refuses unlisted calling sites (#118). The probe must
still read as "running" there: an allowed caller gets the CORS 400, a
refused one a 404 that still carries CORS headers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A target site's own 429 comes back through the worker unchanged, so a
429 alone doesn't say the proxy is rate-limiting. The governor now asks
`diagnoseProxy` on a 429 too (renamed from diagnoseNetworkFailure): the
probe getting 429 itself means the proxy is rate-limiting (breaker +
banner); its usual 400 means the site was, so only that URL backs off.
Without diagnoseProxy every 429 still trips the breaker. The probe
shares the existing once-a-minute throttle.

While the proxy is known to be failing, a failed save or refetch says
so ("Couldn't save: your CORS proxy isn't working…") instead of offering
the "report this article" dialog, and on desktop the banner notes that
extension saves need no proxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained English/Spanish i18n while this branch was open. The banner,
Preferences/Diagnostics status lines, the "Couldn't save/refetch"
snackbars and the Diagnostics panel heading now go through i18next:
shared strings under common.proxyHealth, the panel text in diagnostics.
proxyProblemMessage/proxyProblemSummary take the app's `t`, so
proxyHealth.ts stays free of i18n setup; their tests pin the issue's
exact English copy and check the Spanish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jonocodes
jonocodes force-pushed the feature/implement-86 branch from fd89bb4 to 6e8b1a9 Compare October 16, 2026 18:08
jonocodes added a commit that referenced this pull request Oct 16, 2026
@jonocodes
jonocodes merged commit f6cf4f8 into main Oct 16, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tell the user when their CORS proxy is maxed out

1 participant