fix: retry login across deploy cutovers
This commit is contained in:
1 parent
bfc951cfd9
commit
a57c73055f
4 files changed
+94
-12
No files matched your search
@@ -8,6 +8,7 @@ import {
|
|||||||
CaptchaWidget,
|
CaptchaWidget,
|
||||||
readCaptchaToken,
|
readCaptchaToken,
|
||||||
} from "@/components/auth/captcha-widget";
|
} from "@/components/auth/captcha-widget";
|
||||||
|
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
|
||||||
|
|
||||||
export function HomeLoginForm({
|
export function HomeLoginForm({
|
||||||
captcha = { provider: "none" },
|
captcha = { provider: "none" },
|
||||||
@@ -49,12 +50,14 @@ export function HomeLoginForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const res = await signIn("credentials", {
|
const res = await signInWithTransientRetry(() =>
|
||||||
username,
|
signIn("credentials", {
|
||||||
password,
|
username,
|
||||||
code,
|
password,
|
||||||
redirect: false,
|
code,
|
||||||
});
|
redirect: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
if (!res || res.error) {
|
if (!res || res.error) {
|
||||||
setError(
|
setError(
|
||||||
needs2fa ? "Invalid 2FA code" : "Invalid username or password",
|
needs2fa ? "Invalid 2FA code" : "Invalid username or password",
|
||||||
@@ -62,6 +65,8 @@ export function HomeLoginForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
|
} catch {
|
||||||
|
setError(needs2fa ? "Invalid 2FA code" : "Invalid username or password");
|
||||||
} finally {
|
} finally {
|
||||||
setPending(false);
|
setPending(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
CaptchaWidget,
|
CaptchaWidget,
|
||||||
readCaptchaToken,
|
readCaptchaToken,
|
||||||
} from "@/components/auth/captcha-widget";
|
} from "@/components/auth/captcha-widget";
|
||||||
|
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
|
||||||
|
|
||||||
export function LoginForm({
|
export function LoginForm({
|
||||||
captcha = { provider: "none" },
|
captcha = { provider: "none" },
|
||||||
@@ -52,12 +53,14 @@ export function LoginForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const res = await signIn("credentials", {
|
const res = await signInWithTransientRetry(() =>
|
||||||
username,
|
signIn("credentials", {
|
||||||
password,
|
username,
|
||||||
code,
|
password,
|
||||||
redirect: false,
|
code,
|
||||||
});
|
redirect: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
if (!res || res.error) {
|
if (!res || res.error) {
|
||||||
setError(
|
setError(
|
||||||
needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"),
|
needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"),
|
||||||
@@ -65,6 +68,8 @@ export function LoginForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.href = "/me";
|
window.location.href = "/me";
|
||||||
|
} catch {
|
||||||
|
setError(needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"));
|
||||||
} finally {
|
} finally {
|
||||||
setPending(false);
|
setPending(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
Reference in new issue
Block a user