v1.2.0:2 — retry login/signup server action once on transport failure

iOS Safari reuses a keep-alive socket the server closed while the login
form sat idle during typing, so the first Sign In / Create account POST
dies instantly with NSURLErrorNetworkConnectionLost ("The network
connection was lost"). That rejects the server-action call, hitting the
client-side catch in LoginForm/SignupForm and showing "An unexpected
error occurred"; the second tap lands on a fresh connection and works.

Add lib/retryAction.ts: retryOnTransportError() retries the action once
only when the call throws. A returned { error } (bad password, rate
limit) is a real result and passes straight through. A lost-on-a-stale-
socket POST never reached the server, so retrying it once is safe.
This commit is contained in:
Keysat
2026-06-15 16:44:33 -05:00
parent 56963ab4fd
commit 0178f8f5cc
6 changed files with 108 additions and 3 deletions
+4 -1
View File
@@ -3,6 +3,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { loginAction } from './actions';
import { retryOnTransportError } from '@/lib/retryAction';
export default function LoginForm() {
const router = useRouter();
@@ -17,7 +18,9 @@ export default function LoginForm() {
setLoading(true);
try {
const result = await loginAction(email, password);
const result = await retryOnTransportError(() =>
loginAction(email, password)
);
if (result.error) {
setError(result.error);
+4 -1
View File
@@ -3,6 +3,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { signupAction } from './actions';
import { retryOnTransportError } from '@/lib/retryAction';
export default function SignupForm() {
const router = useRouter();
@@ -19,7 +20,9 @@ export default function SignupForm() {
setLoading(true);
try {
const result = await signupAction(email, password, passwordConfirm, name);
const result = await retryOnTransportError(() =>
signupAction(email, password, passwordConfirm, name)
);
if (result.error) {
setError(result.error);
setLoading(false);
+25
View File
@@ -0,0 +1,25 @@
/**
* Run a server action, retrying it ONCE if the call rejects at the
* transport layer.
*
* iOS Safari (and Safari generally) frequently drops the first POST sent
* on a keep-alive socket that the server closed while the connection sat
* idle — e.g. while the user typed their credentials. The request fails
* instantly with `NSURLErrorNetworkConnectionLost` ("The network
* connection was lost", -1005); a retry lands on a fresh connection and
* succeeds. This is why a first login/signup tap shows "An unexpected
* error occurred" and the second tap works.
*
* Only a *thrown* rejection is retried. A server action that returns a
* value — including an application-level `{ error }` ("Invalid email or
* password", a rate-limit message) — is a real result and passes
* straight through untouched. A lost-on-a-stale-socket POST never
* reached the server, so retrying it once is safe.
*/
export async function retryOnTransportError<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch {
return await fn();
}
}
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect, vi } from 'vitest';
import { retryOnTransportError } from '@/lib/retryAction';
describe('retryOnTransportError', () => {
it('returns the result without retrying when the call succeeds', async () => {
const fn = vi.fn().mockResolvedValue({ success: true });
const result = await retryOnTransportError(fn);
expect(result).toEqual({ success: true });
expect(fn).toHaveBeenCalledTimes(1);
});
it('passes through an application-level { error } without retrying', async () => {
// A returned error (bad password, rate limit) is a real result, not a
// transport failure — it must not trigger a retry.
const fn = vi.fn().mockResolvedValue({ error: 'Invalid email or password' });
const result = await retryOnTransportError(fn);
expect(result).toEqual({ error: 'Invalid email or password' });
expect(fn).toHaveBeenCalledTimes(1);
});
it('retries once on a thrown transport error and returns the second result', async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new TypeError('The network connection was lost'))
.mockResolvedValueOnce({ success: true });
const result = await retryOnTransportError(fn);
expect(result).toEqual({ success: true });
expect(fn).toHaveBeenCalledTimes(2);
});
it('rejects after a single retry when both attempts throw', async () => {
const fn = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
await expect(retryOnTransportError(fn)).rejects.toThrow('Failed to fetch');
expect(fn).toHaveBeenCalledTimes(2);
});
});