fix(slowmode): stop the local cooldown from outgrowing the channel setting (#1795)

This commit is contained in:
Hampus
2026-08-20 19:56:02 +02:00
committed by GitHub
parent 8f4f9a8601
commit 631bc2307a
15 changed files with 497 additions and 147 deletions
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SlowmodeRateLimitError} from '@fluxer/errors/src/domains/core/SlowmodeRateLimitError';
import {describe, expect, it} from 'vitest';
interface SlowmodeResponseBody {
code: string;
retry_after: number;
}
async function readSlowmodeResponse(error: SlowmodeRateLimitError): Promise<{
status: number;
header: string | null;
body: SlowmodeResponseBody;
}> {
const response = error.getResponse();
const body = (await response.json()) as SlowmodeResponseBody;
return {status: response.status, header: response.headers.get('Retry-After'), body};
}
describe('SlowmodeRateLimitError', () => {
it('reports Retry-After in whole seconds and the body in decimal seconds', async () => {
const {status, header, body} = await readSlowmodeResponse(
new SlowmodeRateLimitError({retryAfter: 5, retryAfterDecimal: 4.7}),
);
expect(status).toBe(400);
expect(body.code).toBe('SLOWMODE_RATE_LIMITED');
expect(body.retry_after).toBe(4.7);
expect(header).toBe('5');
});
it('keeps the header and the body within one second of each other', async () => {
const {header, body} = await readSlowmodeResponse(
new SlowmodeRateLimitError({retryAfter: 5, retryAfterDecimal: 4.997}),
);
const headerSeconds = Number(header);
expect(headerSeconds - body.retry_after).toBeLessThan(1);
expect(headerSeconds).toBeGreaterThanOrEqual(body.retry_after);
});
it('falls back to one second when the caller has no retry window', async () => {
const {header, body} = await readSlowmodeResponse(new SlowmodeRateLimitError({retryAfter: undefined}));
expect(header).toBe('1');
expect(body.retry_after).toBe(1);
});
});
@@ -1,25 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {
sanitizeRetryAfterDecimalSeconds,
sanitizeRetryAfterSeconds,
} from '@fluxer/errors/src/domains/core/RetryAfterSeconds';
import {ThrottledError} from '@fluxer/errors/src/domains/core/ThrottledError';
import type {FluxerErrorData} from '@fluxer/errors/src/FluxerError';
type RateLimitScope = 'global' | 'shared' | 'user';
function sanitizeRetryAfter(value: number | undefined | null): number {
if (value == null || !Number.isFinite(value) || value < 0) {
return 1;
}
return Math.max(1, Math.ceil(value));
}
function sanitizeRetryAfterDecimal(value: number | undefined | null, fallback: number): number {
if (value == null || !Number.isFinite(value) || value < 0) {
return fallback;
}
return Math.max(0.001, value);
}
function sanitizeResetTime(resetTime: Date): number {
const timestamp = resetTime.getTime();
if (!Number.isFinite(timestamp)) {
@@ -64,8 +54,8 @@ export class RateLimitError extends ThrottledError {
bucketHash?: string;
scope?: RateLimitScope;
}) {
const safeRetryAfter = sanitizeRetryAfter(retryAfter);
const safeRetryAfterDecimal = sanitizeRetryAfterDecimal(retryAfterDecimal, safeRetryAfter);
const safeRetryAfter = sanitizeRetryAfterSeconds(retryAfter);
const safeRetryAfterDecimal = sanitizeRetryAfterDecimalSeconds(retryAfterDecimal, safeRetryAfter);
const safeResetTimestamp = sanitizeResetTime(resetTime);
const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 1;
const safeResetAfterDecimal =
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export function sanitizeRetryAfterSeconds(value: number | undefined | null): number {
if (value == null || !Number.isFinite(value) || value < 0) {
return 1;
}
return Math.max(1, Math.ceil(value));
}
export function sanitizeRetryAfterDecimalSeconds(value: number | undefined | null, fallback: number): number {
if (value == null || !Number.isFinite(value) || value < 0) {
return fallback;
}
return Math.max(0.001, value);
}
@@ -2,22 +2,28 @@
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
import {
sanitizeRetryAfterDecimalSeconds,
sanitizeRetryAfterSeconds,
} from '@fluxer/errors/src/domains/core/RetryAfterSeconds';
export class SlowmodeRateLimitError extends BadRequestError {
constructor({
retryAfter,
retryAfterDecimal,
}: {
retryAfter: number;
retryAfter: number | undefined;
retryAfterDecimal?: number;
}) {
const safeRetryAfter = sanitizeRetryAfterSeconds(retryAfter);
const safeRetryAfterDecimal = sanitizeRetryAfterDecimalSeconds(retryAfterDecimal, safeRetryAfter);
super({
code: APIErrorCodes.SLOWMODE_RATE_LIMITED,
data: {
retry_after: retryAfterDecimal ?? retryAfter,
retry_after: safeRetryAfterDecimal,
},
headers: {
'Retry-After': retryAfter.toString(),
'Retry-After': safeRetryAfter.toString(),
},
});
}