fix: retry login across deploy cutovers
CI / check (push) Successful in 23s
CI / release (push) Skipped
CI / deploy (push) Successful in 41s

This commit is contained in:
Simo committed 2026-08-02 11:31:35 +02:00
1 parent bfc951cfd9
commit a57c73055f
4 files changed
+94 -12

No files matched your search

+11 -6
View File
@@ -8,6 +8,7 @@ import {
CaptchaWidget,
readCaptchaToken,
} from "@/components/auth/captcha-widget";
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
export function HomeLoginForm({
captcha = { provider: "none" },
@@ -49,12 +50,14 @@ export function HomeLoginForm({
return;
}
}
const res = await signIn("credentials", {
username,
password,
code,
redirect: false,
});
const res = await signInWithTransientRetry(() =>
signIn("credentials", {
username,
password,
code,
redirect: false,
}),
);
if (!res || res.error) {
setError(
needs2fa ? "Invalid 2FA code" : "Invalid username or password",
@@ -62,6 +65,8 @@ export function HomeLoginForm({
return;
}
window.location.href = "/";
} catch {
setError(needs2fa ? "Invalid 2FA code" : "Invalid username or password");
} finally {
setPending(false);
}
+11 -6
View File
@@ -10,6 +10,7 @@ import {
CaptchaWidget,
readCaptchaToken,
} from "@/components/auth/captcha-widget";
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
export function LoginForm({
captcha = { provider: "none" },
@@ -52,12 +53,14 @@ export function LoginForm({
return;
}
}
const res = await signIn("credentials", {
username,
password,
code,
redirect: false,
});
const res = await signInWithTransientRetry(() =>
signIn("credentials", {
username,
password,
code,
redirect: false,
}),
);
if (!res || res.error) {
setError(
needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"),
@@ -65,6 +68,8 @@ export function LoginForm({
return;
}
window.location.href = "/me";
} catch {
setError(needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"));
} finally {
setPending(false);
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import { signInWithTransientRetry } from "./sign-in-retry";
describe("signInWithTransientRetry", () => {
it("retries thrown transport errors with a short backoff", async () => {
const transportError = new SyntaxError("unexpected character in JSON");
const signIn = vi
.fn<() => Promise<{ ok: boolean }>>()
.mockRejectedValueOnce(transportError)
.mockRejectedValueOnce(transportError)
.mockResolvedValue({ ok: true });
const wait = vi.fn(async () => undefined);
await expect(signInWithTransientRetry(signIn, wait)).resolves.toEqual({
ok: true,
});
expect(signIn).toHaveBeenCalledTimes(3);
expect(wait).toHaveBeenNthCalledWith(1, 400);
expect(wait).toHaveBeenNthCalledWith(2, 800);
});
it("returns ordinary auth responses without retrying", async () => {
const response = { ok: false, error: "CredentialsSignin" };
const signIn = vi.fn(async () => response);
const wait = vi.fn(async () => undefined);
await expect(signInWithTransientRetry(signIn, wait)).resolves.toBe(
response,
);
expect(signIn).toHaveBeenCalledOnce();
expect(wait).not.toHaveBeenCalled();
});
it("rethrows the last transport error after four attempts", async () => {
const transportError = new Error("temporary upstream failure");
const signIn = vi
.fn<() => Promise<never>>()
.mockRejectedValue(transportError);
const wait = vi.fn(async () => undefined);
await expect(signInWithTransientRetry(signIn, wait)).rejects.toBe(
transportError,
);
expect(signIn).toHaveBeenCalledTimes(4);
expect(wait).toHaveBeenCalledTimes(3);
});
});
+25
View File
@@ -0,0 +1,25 @@
type Wait = (delayMs: number) => Promise<unknown>;
const waitForDelay: Wait = (delayMs) =>
new Promise((resolve) => setTimeout(resolve, delayMs));
export async function signInWithTransientRetry<T>(
signIn: () => Promise<T>,
wait: Wait = waitForDelay,
): Promise<T> {
const maxAttempts = 4;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await signIn();
} catch (error) {
if (attempt === maxAttempts - 1) {
throw error;
}
await wait(400 * 2 ** attempt);
}
}
throw new Error("Unreachable auth retry state");
}