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
+18 -10
View File
@@ -335,22 +335,30 @@
return;
}
// Silent retry on the first fetch — iOS Safari sometimes
// aborts the very first request from a cold tab with a generic
// "Load failed" TypeError. A single ~500ms retry hides the
// flake; server-side errors (4xx/5xx) are not retried because
// they're deliberate responses, not transport issues.
// Silent retry on transport failures — iOS Safari can dispatch a
// POST onto a pooled keep-alive socket the server (or a proxy in
// front of it) has already closed. Unlike a GET, a non-idempotent
// POST isn't transparently re-sent on a fresh connection; it
// surfaces a "Load failed" TypeError instead. A single quick retry
// tends to reuse the same dead socket and fail again (the reported
// "first tap errors, second works"), so retry a few times with
// growing backoff to outlast Safari evicting the socket. Server
// errors (4xx/5xx) are returned as-is and never retried — they're
// deliberate responses, not transport flakes.
const reqInit = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
};
async function postWithRetry() {
try {
return await fetch('/auth/request-link', reqInit);
} catch (e) {
await new Promise((r) => setTimeout(r, 500));
return await fetch('/auth/request-link', reqInit);
const backoffsMs = [400, 1200];
for (let attempt = 0; ; attempt++) {
try {
return await fetch('/auth/request-link', reqInit);
} catch (e) {
if (attempt >= backoffsMs.length) throw e;
await new Promise((r) => setTimeout(r, backoffsMs[attempt]));
}
}
}
try {