fix(rate-limit): convert a string lastRequest before computing X-Retry-After (#11521)

Co-authored-by: Taesu <bytaesu@gmail.com>
This commit is contained in:
Aryan
2026-10-15 15:02:04 +00:00
committed by GitHub
co-authored by Taesu
parent 41f9ff6162
commit 37a75c95d1
3 changed files with 63 additions and 1 deletions
@@ -0,0 +1,5 @@
---
"better-auth": patch
---
Fix database-backed rate limiting returning an excessively large `X-Retry-After` header instead of the remaining seconds in the rate-limit window.
@@ -125,7 +125,7 @@ function createDatabaseStorageWrapper(
where: [{ field: "key", value: key }],
});
const data = res[0];
if (typeof data?.lastRequest === "bigint") {
if (data) {
data.lastRequest = Number(data.lastRequest);
}
return data;
@@ -439,6 +439,63 @@ describe("database rate-limit cleanup scheduling", async () => {
});
});
/**
* @see https://github.com/better-auth/better-auth/issues/11519
*/
describe("database rate-limit with a string lastRequest", async () => {
const { auth, client, testUser } = await getTestInstance({
rateLimit: {
enabled: true,
storage: "database",
customRules: {
"/sign-in/email": { window: 60, max: 1 },
},
},
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should respond with the remaining seconds in the retry-after header", async () => {
const now = Date.now();
const clock = vi.spyOn(Date, "now").mockReturnValue(now);
// node-postgres returns `int8` columns as strings by default.
const { adapter } = await auth.$context;
const findMany = adapter.findMany.bind(adapter);
vi.spyOn(adapter, "findMany").mockImplementation(async (args) => {
const rows = await findMany(args);
if (args.model !== "rateLimit") return rows;
return rows.map((row) => ({
...(row as RateLimit),
lastRequest: String((row as RateLimit).lastRequest),
})) as typeof rows;
});
const first = await client.signIn.email({
email: testUser.email,
password: testUser.password,
});
expect(first.error).toBeNull();
clock.mockReturnValue(now + 1_001);
let retryAfter = "";
const blocked = await client.signIn.email(
{
email: testUser.email,
password: testUser.password,
},
{
onError(context) {
retryAfter = context.response.headers.get("X-Retry-After") ?? "";
},
},
);
expect(blocked.error?.status).toBe(429);
expect(retryAfter).toBe("59");
});
});
describe("custom rate limiting storage", async () => {
const store = new Map<string, string>();
const expirationMap = new Map<string, number>();