Harden iOS sign-in against stale-connection POST failures

iPad users hit a spurious "network error" on the first tap of
"Send sign-in link", with a second tap succeeding. Cause is iOS
Safari dispatching the POST onto a pooled keep-alive socket the
server/proxy already closed; unlike a GET it isn't transparently
re-sent, so it surfaces as a transport TypeError. The single 500ms
auto-retry was too quick and reused the same dead socket.

Both sign-in entry points (auth.html postWithRetry, index.html
fetchWithRetry) now retry 3x with growing backoff (0 -> +400ms ->
+1.6s) to outlast Safari evicting the socket. Frontend-only.

Ships as 0.2.156.
This commit is contained in:
Keysat
2026-06-15 16:35:13 -05:00
parent aca2ba9e2e
commit 91af0b711e
5 changed files with 53 additions and 27 deletions
+17 -14
View File
@@ -6020,21 +6020,24 @@
render();
}
// fetchWithRetry — wraps fetch with one silent retry on a network
// failure (TypeError). iOS Safari sometimes silently aborts the
// first fetch from a cold tab and surfaces it as "Load failed",
// which the user sees as a misleading error on flows where the
// server would have happily processed the request. Short backoff,
// single retry. Server errors (4xx/5xx) are NOT retried — those
// are deliberate responses, not transport flakes.
// fetchWithRetry — wraps fetch with silent retries on a transport
// failure (TypeError). iOS Safari can dispatch a request onto a
// pooled keep-alive socket the server (or a proxy in front of it)
// has already closed; for non-idempotent POSTs it surfaces a "Load
// failed" TypeError instead of transparently re-sending on a fresh
// connection. A single quick retry often reuses the same dead socket
// and fails again, so retry a few times with growing backoff to
// outlast Safari evicting the socket. Server errors (4xx/5xx) are
// returned as-is and NOT retried — those are deliberate responses.
async function fetchWithRetry(input, init) {
try {
return await fetch(input, init);
} catch (err) {
// Wait ~500ms then try once more. The TCP/TLS state is warm
// by then and the second attempt nearly always succeeds.
await new Promise((r) => setTimeout(r, 500));
return await fetch(input, init);
const backoffsMs = [400, 1200];
for (let attempt = 0; ; attempt++) {
try {
return await fetch(input, init);
} catch (err) {
if (attempt >= backoffsMs.length) throw err;
await new Promise((r) => setTimeout(r, backoffsMs[attempt]));
}
}
}