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:
+18
-10
@@ -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 {
|
||||
|
||||
+17
-14
@@ -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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user