From 2bcac80092dd0fe14b2800cd47ef9c0b96e66bc8 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 8 Dec 2024 11:56:26 -0500 Subject: [PATCH] use fraction seconds for rate limit headers --- packages/backend/src/misc/rate-limit-utils.ts | 14 +++-- .../test/unit/misc/rate-limit-utils-tests.ts | 62 +++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 packages/backend/test/unit/misc/rate-limit-utils-tests.ts diff --git a/packages/backend/src/misc/rate-limit-utils.ts b/packages/backend/src/misc/rate-limit-utils.ts index 00c0701ad6..6336f29cb7 100644 --- a/packages/backend/src/misc/rate-limit-utils.ts +++ b/packages/backend/src/misc/rate-limit-utils.ts @@ -126,14 +126,20 @@ export function hasMinLimit(limit: LegacyRateLimit): limit is LegacyRateLimit & export function sendRateLimitHeaders(reply: FastifyReply, info: LimitInfo): void { // Number of seconds until the limit has fully reset. - reply.header('X-RateLimit-Clear', info.fullResetSec.toString()); + const clear = (info.fullResetMs / 1000).toFixed(3); + reply.header('X-RateLimit-Clear', clear); + // Number of calls that can be made before being limited. - reply.header('X-RateLimit-Remaining', info.remaining.toString()); + const remaining = info.remaining.toString(); + reply.header('X-RateLimit-Remaining', remaining); if (info.blocked) { // Number of seconds to wait before trying again. Left for backwards compatibility. - reply.header('Retry-After', info.resetSec.toString()); + const retry = info.resetSec.toString(); + reply.header('Retry-After', retry); + // Number of milliseconds to wait before trying again. - reply.header('X-RateLimit-Reset', info.resetMs.toString()); + const reset = (info.resetMs / 1000).toFixed(3); + reply.header('X-RateLimit-Reset', reset); } } diff --git a/packages/backend/test/unit/misc/rate-limit-utils-tests.ts b/packages/backend/test/unit/misc/rate-limit-utils-tests.ts new file mode 100644 index 0000000000..ea56d170f7 --- /dev/null +++ b/packages/backend/test/unit/misc/rate-limit-utils-tests.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { jest } from '@jest/globals'; +import { Mock } from 'jest-mock'; +import type { FastifyReply } from 'fastify'; +import { LimitInfo, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +describe(sendRateLimitHeaders, () => { + let mockHeader: Mock<((name: string, value: unknown) => void)> = null!; + let mockReply: FastifyReply = null!; + let fakeInfo: LimitInfo = null!; + + beforeEach(() => { + mockHeader = jest.fn<((name: string, value: unknown) => void)>(); + mockReply = { + header: mockHeader, + } as unknown as FastifyReply; + fakeInfo = { + blocked: false, + remaining: 1, + resetSec: 1, + resetMs: 567, + fullResetSec: 10, + fullResetMs: 9876, + }; + }); + + it('should send X-RateLimit-Clear', () => { + sendRateLimitHeaders(mockReply, fakeInfo); + + expect(mockHeader).toHaveBeenCalledWith('X-RateLimit-Clear', '9.876'); + }); + + it('should send X-RateLimit-Remaining', () => { + sendRateLimitHeaders(mockReply, fakeInfo); + + expect(mockHeader).toHaveBeenCalledWith('X-RateLimit-Remaining', '1'); + }); + + describe('when limit is blocked', () => { + it('should send X-RateLimit-Reset', () => { + fakeInfo.blocked = true; + + sendRateLimitHeaders(mockReply, fakeInfo); + + expect(mockHeader).toHaveBeenCalledWith('X-RateLimit-Reset', '0.567'); + }); + + it('should send Retry-After', () => { + fakeInfo.blocked = true; + + sendRateLimitHeaders(mockReply, fakeInfo); + + expect(mockHeader).toHaveBeenCalledWith('Retry-After', '1'); + }); + }); +});