diff --git a/src/components/auth/home-login-form.tsx b/src/components/auth/home-login-form.tsx index 556a932e..584e70ac 100644 --- a/src/components/auth/home-login-form.tsx +++ b/src/components/auth/home-login-form.tsx @@ -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); } diff --git a/src/components/auth/login-form.tsx b/src/components/auth/login-form.tsx index b5bdf3d6..f76d7801 100644 --- a/src/components/auth/login-form.tsx +++ b/src/components/auth/login-form.tsx @@ -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); } diff --git a/src/lib/auth/sign-in-retry.test.ts b/src/lib/auth/sign-in-retry.test.ts new file mode 100644 index 00000000..767fa96f --- /dev/null +++ b/src/lib/auth/sign-in-retry.test.ts @@ -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>() + .mockRejectedValue(transportError); + const wait = vi.fn(async () => undefined); + + await expect(signInWithTransientRetry(signIn, wait)).rejects.toBe( + transportError, + ); + expect(signIn).toHaveBeenCalledTimes(4); + expect(wait).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/lib/auth/sign-in-retry.ts b/src/lib/auth/sign-in-retry.ts new file mode 100644 index 00000000..05570552 --- /dev/null +++ b/src/lib/auth/sign-in-retry.ts @@ -0,0 +1,25 @@ +type Wait = (delayMs: number) => Promise; + +const waitForDelay: Wait = (delayMs) => + new Promise((resolve) => setTimeout(resolve, delayMs)); + +export async function signInWithTransientRetry( + signIn: () => Promise, + wait: Wait = waitForDelay, +): Promise { + 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"); +}