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
@@ -929,9 +929,8 @@ export class MessageSendService {
algorithm: 'leaky_bucket',
});
if (!slowmodeResult.allowed) {
const retryAfter = Math.max(0, slowmodeResult.resetTime.getTime() - Date.now());
throw new SlowmodeRateLimitError({
retryAfter,
retryAfter: slowmodeResult.retryAfter,
retryAfterDecimal: slowmodeResult.retryAfterDecimal,
});
}
@@ -52,4 +52,27 @@ describe('Slowmode Enforcement', () => {
expect(messages).toHaveLength(1);
expect(messages[0]?.id).toBe(firstMessage.id);
});
it('reports the slowmode retry window in seconds on the Retry-After header', async () => {
const rateLimitPerUser = 5;
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0]!;
await ensureSessionStarted(harness, member.token);
const channel = await createChannel(harness, owner.token, guild.id, 'slowmode-channel');
await updateChannel(harness, owner.token, channel.id, {
rate_limit_per_user: rateLimitPerUser,
});
await sendChannelMessage(harness, member.token, channel.id, 'first message');
const {response, json} = await createBuilder<{code: string; retry_after: number}>(harness, member.token)
.post(`/channels/${channel.id}/messages`)
.body({content: 'second message'})
.expect(400, APIErrorCodes.SLOWMODE_RATE_LIMITED)
.executeWithResponse();
const headerRetryAfter = Number(response.headers.get('Retry-After'));
expect(Number.isInteger(headerRetryAfter)).toBe(true);
expect(headerRetryAfter).toBeGreaterThan(0);
expect(headerRetryAfter).toBeLessThanOrEqual(rateLimitPerUser);
expect(json.retry_after).toBeGreaterThan(0);
expect(json.retry_after).toBeLessThanOrEqual(rateLimitPerUser);
expect(headerRetryAfter - json.retry_after).toBeLessThan(1);
});
});
@@ -834,7 +834,7 @@ export async function forward(
logger.warn(`Forward send failed in channel ${channelId}`);
return false;
}
SlowmodeCommands.recordMessageSend(channelId);
SlowmodeCommands.confirmMessageSend(channelId, forwardedMessage.timestamp);
if (optionalMessage) {
const commentNonce = SnowflakeUtils.fromTimestamp(Date.now() + 1);
const commentMessage = await send(channelId, {
@@ -845,7 +845,7 @@ export async function forward(
logger.warn(`Forward comment send failed in channel ${channelId}`);
return false;
}
SlowmodeCommands.recordMessageSend(channelId);
SlowmodeCommands.confirmMessageSend(channelId, commentMessage.timestamp);
}
}
logger.debug('Successfully forwarded message to all channels');
@@ -24,6 +24,7 @@ import {
normalizeMessageContent,
} from '@app/features/messaging/utils/MessageRequestUtils';
import * as MessageSubmitUtils from '@app/features/messaging/utils/MessageSubmitUtils';
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
import {MatureContentRejectedModal} from '@app/features/moderation/components/alerts/MatureContentRejectedModal';
import {http} from '@app/features/platform/transport/RestTransport';
import {HttpError} from '@app/features/platform/types/EndpointError';
@@ -66,9 +67,7 @@ type ScheduledMessageRequest = MessageCreateRequest & {
interface ApiErrorBody {
code?: number | string;
retry_after?: number;
message?: string;
details?: unknown;
}
export interface ScheduleMessageParams {
@@ -372,37 +371,6 @@ const getApiErrorBody = (error: HttpError): ApiErrorBody | undefined => {
return typeof error.body === 'object' && error.body !== null ? (error.body as ApiErrorBody) : undefined;
};
function parseNestedRetryAfterSeconds(body: ApiErrorBody | undefined): number | undefined {
if (body === undefined || typeof body.details !== 'object' || body.details === null || Array.isArray(body.details)) {
return undefined;
}
const details = body.details as Record<string, unknown>;
const retry = details.retry;
if (typeof retry !== 'object' || retry === null || Array.isArray(retry)) return undefined;
const afterSeconds = (retry as Record<string, unknown>).after_seconds;
if (typeof afterSeconds !== 'number' || !Number.isFinite(afterSeconds) || afterSeconds <= 0) return undefined;
return afterSeconds;
}
function resolveRetryAfterSeconds(error: HttpError): number | undefined {
const body = getApiErrorBody(error);
const nestedRetryAfter = parseNestedRetryAfterSeconds(body);
if (nestedRetryAfter !== undefined) return nestedRetryAfter;
const bodyRetryAfter = body === undefined ? undefined : body.retry_after;
if (typeof bodyRetryAfter === 'number' && Number.isFinite(bodyRetryAfter) && bodyRetryAfter > 0) {
return bodyRetryAfter;
}
const responseHeaders: Record<string, string> | undefined = error.responseHeaders;
const header = responseHeaders === undefined ? undefined : responseHeaders['retry-after'];
if (header === undefined || header.trim() === '') return undefined;
const numeric = Number(header);
if (Number.isFinite(numeric) && numeric > 0) return numeric;
const deadline = Date.parse(header);
if (!Number.isFinite(deadline)) return undefined;
const remainingSeconds = (deadline - Date.now()) / 1000;
return remainingSeconds > 0 ? remainingSeconds : undefined;
}
function handleScheduleError(
i18n: I18n,
error: unknown,
@@ -430,8 +398,7 @@ function handleScheduleError(
return;
}
if (isSlowmodeError(error)) {
const retryAfterSeconds = resolveRetryAfterSeconds(error);
const retryAfterMs = SlowmodeCommands.retryAfterSecondsToMs(retryAfterSeconds);
const retryAfterMs = SlowmodeCommands.clampSlowmodeRetryAfterMs(resolveRetryAfterMs(error));
if (retryAfterMs <= 0) {
ModalCommands.push(
modal(() => (
@@ -490,8 +457,8 @@ function handleScheduleError(
}
function handleScheduleRateLimit(_i18n: I18n, error: HttpError): void {
const retryAfterSecondsValue = resolveRetryAfterSeconds(error);
const retryAfterSeconds = retryAfterSecondsValue === undefined ? undefined : Math.ceil(retryAfterSecondsValue);
const retryAfterMs = resolveRetryAfterMs(error);
const retryAfterSeconds = retryAfterMs === null ? undefined : Math.ceil(retryAfterMs / 1000);
ModalCommands.push(
modal(() => (
<MessageSendTooQuickModal
@@ -131,6 +131,7 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag
referenced_message: referencedMessage?.toJSON(),
});
SlowmodeCommands.prepareMessageSend(channel.id);
const pendingSend = SlowmodeCommands.recordPendingMessageSend(channel.id);
void MessageCommands.send(channel.id, {
content: message.content,
nonce,
@@ -141,11 +142,17 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag
stickers,
favoriteMemeId,
tts,
}).then((sentMessage) => {
if (sentMessage) {
SlowmodeCommands.recordMessageSend(channel.id);
}
});
})
.then((sentMessage) => {
if (sentMessage) {
SlowmodeCommands.confirmMessageSend(channel.id, sentMessage.timestamp, pendingSend);
return;
}
SlowmodeCommands.discardPendingMessageSend(channel.id, pendingSend);
})
.catch(() => {
SlowmodeCommands.discardPendingMessageSend(channel.id, pendingSend);
});
ComponentDispatch.dispatch('MESSAGE_SENT', {channelId: channel.id});
return true;
},
@@ -196,6 +203,7 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag
});
SlowmodeCommands.prepareMessageSend(channel.id);
const allowedMentions: AllowedMentions = {replied_user: replyingMessage?.mentioning ?? true};
const pendingSend = SlowmodeCommands.recordPendingMessageSend(channel.id);
void MessageCommands.send(channel.id, {
content: messageData.content,
nonce,
@@ -207,11 +215,17 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag
flags: 0,
stickers: messageData.stickers || [],
favoriteMemeId: sendOptions.favoriteMemeId,
}).then((sentMessage) => {
if (sentMessage) {
SlowmodeCommands.recordMessageSend(channel.id);
}
});
})
.then((sentMessage) => {
if (sentMessage) {
SlowmodeCommands.confirmMessageSend(channel.id, sentMessage.timestamp, pendingSend);
return;
}
SlowmodeCommands.discardPendingMessageSend(channel.id, pendingSend);
})
.catch(() => {
SlowmodeCommands.discardPendingMessageSend(channel.id, pendingSend);
});
ComponentDispatch.dispatch('MESSAGE_SENT', {channelId: channel.id});
},
[channel?.id, referencedMessage, replyingMessage],
@@ -38,6 +38,7 @@ import {
type MessageEditRequest,
normalizeMessageEditContent,
} from '@app/features/messaging/utils/MessageRequestUtils';
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
import {MatureContentRejectedModal} from '@app/features/moderation/components/alerts/MatureContentRejectedModal';
import {http} from '@app/features/platform/transport/RestTransport';
import {HttpError} from '@app/features/platform/types/EndpointError';
@@ -141,9 +142,7 @@ export interface RetryError {
export interface ApiErrorBody {
code?: number | string;
retry_after?: number;
message?: string;
details?: unknown;
}
interface PresignedAttachmentUploadSinglepartResponse {
@@ -225,70 +224,25 @@ const getApiErrorBody = (error: HttpError): ApiErrorBody | undefined => {
};
interface MessageRateLimitRetry {
retryAfterSeconds: number | null;
retryAfterMs: number | null;
automaticRetryDelayMs: number | null;
}
function parsePositiveRetryAfterSeconds(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null;
return value;
}
function parseRetryAfterHeaderSeconds(value: string | undefined): number | null {
if (value === undefined || value.trim() === '') return null;
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) return numeric;
const deadline = Date.parse(value);
if (!Number.isFinite(deadline)) return null;
const remainingSeconds = (deadline - Date.now()) / 1000;
return remainingSeconds > 0 ? remainingSeconds : null;
}
function parseNestedRetryAfterSeconds(body: ApiErrorBody | undefined): number | null {
if (body === undefined || typeof body.details !== 'object' || body.details === null || Array.isArray(body.details)) {
return null;
}
const details = body.details as Record<string, unknown>;
const retry = details.retry;
if (typeof retry !== 'object' || retry === null || Array.isArray(retry)) return null;
const afterSeconds = (retry as Record<string, unknown>).after_seconds;
if (typeof afterSeconds !== 'number' || !Number.isFinite(afterSeconds) || afterSeconds <= 0) return null;
return afterSeconds;
}
function readRateLimitHeader(error: HttpError, name: string): string | undefined {
return error.responseHeaders[name.toLowerCase()];
}
function resolveMessageRateLimitRetry(error: HttpError): MessageRateLimitRetry {
const body = getApiErrorBody(error);
const candidates: Array<number> = [];
const nestedRetryAfter = parseNestedRetryAfterSeconds(body);
if (nestedRetryAfter !== null) candidates.push(nestedRetryAfter);
const bodyRetryAfter = body === undefined ? undefined : body.retry_after;
const parsedBodyRetryAfter = parsePositiveRetryAfterSeconds(bodyRetryAfter);
if (parsedBodyRetryAfter !== null) candidates.push(parsedBodyRetryAfter);
const parsedHeaderRetryAfter = parseRetryAfterHeaderSeconds(readRateLimitHeader(error, 'retry-after'));
if (parsedHeaderRetryAfter !== null) candidates.push(parsedHeaderRetryAfter);
const resetAfterHeader = readRateLimitHeader(error, 'x-ratelimit-reset-after');
if (resetAfterHeader !== undefined) {
const parsedResetAfter = parsePositiveRetryAfterSeconds(Number(resetAfterHeader));
if (parsedResetAfter !== null) candidates.push(parsedResetAfter);
}
if (candidates.length === 0) {
return {retryAfterSeconds: null, automaticRetryDelayMs: null};
}
const rawRetryAfter = Math.max(...candidates);
const retryAfterSeconds = Math.ceil(rawRetryAfter);
const retryAfterMs = Math.ceil(rawRetryAfter * 1000);
if (!Number.isSafeInteger(retryAfterSeconds) || !Number.isSafeInteger(retryAfterMs)) {
return {retryAfterSeconds: null, automaticRetryDelayMs: null};
const retryAfterMs = resolveRetryAfterMs(error);
if (retryAfterMs === null) {
return {retryAfterMs: null, automaticRetryDelayMs: null};
}
let automaticRetryDelayMs: number | null = null;
if (retryAfterMs <= MESSAGE_SEND_RATE_LIMIT_MAX_AUTOMATIC_DELAY_MS) {
automaticRetryDelayMs = retryAfterMs;
}
return {retryAfterSeconds, automaticRetryDelayMs};
return {retryAfterMs, automaticRetryDelayMs};
}
function retryAfterMsToWholeSeconds(retryAfterMs: number | null): number | null {
if (retryAfterMs === null) return null;
return Math.ceil(retryAfterMs / 1000);
}
const isAbortError = (error: unknown): boolean => {
return error instanceof DOMException && error.name === 'AbortError';
@@ -1344,7 +1298,7 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
this.restoreFailedMessage(payload.channelId, payload.nonce);
}
completed(null, undefined, error);
this.handleRateLimitError(retry.retryAfterSeconds);
this.handleRateLimitError(retryAfterMsToWholeSeconds(retry.retryAfterMs));
}
private handleSendError(
@@ -1425,9 +1379,7 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
);
} else if (error instanceof HttpError && isSlowmodeError(error)) {
const retry = resolveMessageRateLimitRetry(error);
const retryAfterMs = SlowmodeCommands.retryAfterSecondsToMs(
retry.retryAfterSeconds === null ? undefined : retry.retryAfterSeconds,
);
const retryAfterMs = SlowmodeCommands.clampSlowmodeRetryAfterMs(retry.retryAfterMs);
if (retryAfterMs <= 0) {
ModalCommands.push(
modal(() => (
@@ -1579,7 +1531,7 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
return;
}
completed(null, undefined, error);
this.handleEditRateLimitError(retry.retryAfterSeconds);
this.handleEditRateLimitError(retryAfterMsToWholeSeconds(retry.retryAfterMs));
}
private showEditErrorModal(error: HttpError): void {
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
import {HttpError} from '@app/features/platform/types/EndpointError';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {describe, expect, it} from 'vitest';
function slowmodeRejection(body: unknown, responseHeaders: Record<string, string>): HttpError {
return new HttpError({
method: 'POST',
path: '/channels/1234567890123456789/messages',
status: 400,
body,
responseHeaders,
});
}
describe('resolveRetryAfterMs', () => {
it('uses the decimal seconds from the body instead of the Retry-After header', () => {
const error = slowmodeRejection(
{code: APIErrorCodes.SLOWMODE_RATE_LIMITED, retry_after: 4.44},
{'retry-after': '5'},
);
expect(resolveRetryAfterMs(error)).toBe(4440);
});
it('never multiplies a millisecond Retry-After header into a longer window than the body', () => {
const error = slowmodeRejection(
{code: APIErrorCodes.SLOWMODE_RATE_LIMITED, retry_after: 4.7},
{'retry-after': '4700'},
);
expect(resolveRetryAfterMs(error)).toBe(4700);
});
it('prefers a nested retry window over every other source', () => {
const error = slowmodeRejection(
{code: APIErrorCodes.SLOWMODE_RATE_LIMITED, retry_after: 30, details: {retry: {after_seconds: 2.5}}},
{'retry-after': '30'},
);
expect(resolveRetryAfterMs(error)).toBe(2500);
});
it('falls back to the Retry-After header when the body carries no window', () => {
const error = slowmodeRejection({code: APIErrorCodes.SLOWMODE_RATE_LIMITED}, {'retry-after': '3'});
expect(resolveRetryAfterMs(error)).toBe(3000);
});
it('falls back to the reset-after header when nothing else is present', () => {
const error = slowmodeRejection({code: APIErrorCodes.RATE_LIMITED}, {'x-ratelimit-reset-after': '1.25'});
expect(resolveRetryAfterMs(error)).toBe(1250);
});
it('reads an HTTP date Retry-After header as a remaining duration', () => {
const deadline = new Date(Date.now() + 4000).toUTCString();
const error = slowmodeRejection({code: APIErrorCodes.SLOWMODE_RATE_LIMITED}, {'retry-after': deadline});
const retryAfterMs = resolveRetryAfterMs(error);
expect(retryAfterMs).not.toBeNull();
expect(retryAfterMs!).toBeGreaterThan(0);
expect(retryAfterMs!).toBeLessThanOrEqual(4000);
});
it('returns null when no retry window is advertised', () => {
expect(resolveRetryAfterMs(slowmodeRejection({code: APIErrorCodes.SLOWMODE_RATE_LIMITED}, {}))).toBeNull();
});
});
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {HttpError} from '@app/features/platform/types/EndpointError';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseSeconds(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null;
return value;
}
function parseNestedSeconds(body: Record<string, unknown> | undefined): number | null {
if (body === undefined || !isRecord(body.details)) return null;
const retry = body.details.retry;
if (!isRecord(retry)) return null;
return parseSeconds(retry.after_seconds);
}
function parseHeaderSeconds(value: string | undefined): number | null {
if (value === undefined || value.trim() === '') return null;
const numeric = Number(value);
if (Number.isFinite(numeric)) return parseSeconds(numeric);
const deadline = Date.parse(value);
if (!Number.isFinite(deadline)) return null;
return parseSeconds((deadline - Date.now()) / 1000);
}
function resolveRetryAfterSeconds(error: HttpError): number | null {
const body = isRecord(error.body) ? error.body : undefined;
const nestedSeconds = parseNestedSeconds(body);
if (nestedSeconds !== null) return nestedSeconds;
const bodySeconds = body === undefined ? null : parseSeconds(body.retry_after);
if (bodySeconds !== null) return bodySeconds;
const headerSeconds = parseHeaderSeconds(error.responseHeaders['retry-after']);
if (headerSeconds !== null) return headerSeconds;
return parseHeaderSeconds(error.responseHeaders['x-ratelimit-reset-after']);
}
export function resolveRetryAfterMs(error: HttpError): number | null {
const retryAfterSeconds = resolveRetryAfterSeconds(error);
if (retryAfterSeconds === null) return null;
const retryAfterMs = Math.ceil(retryAfterSeconds * 1000);
if (!Number.isSafeInteger(retryAfterMs)) return null;
return retryAfterMs;
}
@@ -6,32 +6,47 @@ import {CHANNEL_RATE_LIMIT_PER_USER_MAX} from '@fluxer/constants/src/LimitConsta
const MAX_RETRY_AFTER_MS = CHANNEL_RATE_LIMIT_PER_USER_MAX * 1000;
function clearSendScopedSticker(channelId: string): void {
ChannelSticker.clearPendingStickerOnMessageSend(channelId);
export interface PendingMessageSend {
readonly previousSendTimestamp: number | null;
readonly pendingSendTimestamp: number;
}
function markSlowmodeSend(channelId: string): void {
Slowmode.recordMessageSend(channelId);
function clearSendScopedSticker(channelId: string): void {
ChannelSticker.clearPendingStickerOnMessageSend(channelId);
}
export function prepareMessageSend(channelId: string): void {
clearSendScopedSticker(channelId);
}
export function recordMessageSend(channelId: string): void {
markSlowmodeSend(channelId);
export function recordPendingMessageSend(channelId: string): PendingMessageSend {
const previousSendTimestamp = Slowmode.getLastSendTimestamp(channelId);
const pendingSendTimestamp = Slowmode.recordMessageSend(channelId);
return {previousSendTimestamp, pendingSendTimestamp};
}
export function confirmMessageSend(channelId: string, sentAt: string, pending?: PendingMessageSend): void {
const timestamp = Date.parse(sentAt);
if (!Number.isFinite(timestamp)) return;
const floor = pending?.pendingSendTimestamp;
const anchored = floor == null ? timestamp : Math.max(timestamp, floor);
Slowmode.updateSlowmodeTimestamp(channelId, anchored);
}
export function discardPendingMessageSend(channelId: string, pending: PendingMessageSend): void {
if (Slowmode.getLastSendTimestamp(channelId) !== pending.pendingSendTimestamp) return;
Slowmode.updateSlowmodeTimestamp(channelId, pending.previousSendTimestamp);
}
export function updateSlowmodeRemaining(channelId: string, retryAfterMs: number): void {
Slowmode.updateSlowmodeRemaining(channelId, retryAfterMs);
}
export function retryAfterSecondsToMs(retryAfterSeconds: number | undefined): number {
if (retryAfterSeconds == null || !Number.isFinite(retryAfterSeconds) || retryAfterSeconds <= 0) {
export function clampSlowmodeRetryAfterMs(retryAfterMs: number | null): number {
if (retryAfterMs == null || !Number.isSafeInteger(retryAfterMs) || retryAfterMs <= 0) {
return 0;
}
const retryAfterMs = Math.ceil(retryAfterSeconds * 1000);
if (!Number.isSafeInteger(retryAfterMs) || retryAfterMs > MAX_RETRY_AFTER_MS) {
if (retryAfterMs > MAX_RETRY_AFTER_MS) {
return 0;
}
return retryAfterMs;
@@ -52,7 +52,7 @@ class Slowmode {
this.pruneExpired(Date.now());
}
recordMessageSend(channelId: string): void {
recordMessageSend(channelId: string): number {
const now = Date.now();
this.pruneExpired(now);
const current = this.getEntry(channelId);
@@ -60,17 +60,22 @@ class Slowmode {
explicitExpiresAt: current.explicitExpiresAt,
lastSendTimestamp: now,
});
return now;
}
updateSlowmodeTimestamp(channelId: string, timestamp: number): void {
updateSlowmodeTimestamp(channelId: string, timestamp: number | null): void {
const now = Date.now();
if (!isValidTimestamp(timestamp, now)) return;
let nextTimestamp = timestamp;
if (nextTimestamp !== null) {
if (!isValidTimestamp(nextTimestamp, now)) return;
nextTimestamp = Math.min(nextTimestamp, now);
}
this.pruneExpired(now);
const current = this.getEntry(channelId);
if (current.lastSendTimestamp === timestamp) return;
if (current.lastSendTimestamp === nextTimestamp) return;
this.setEntry(channelId, {
explicitExpiresAt: current.explicitExpiresAt,
lastSendTimestamp: timestamp,
lastSendTimestamp: nextTimestamp,
});
}
@@ -0,0 +1,206 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
import {HttpError} from '@app/features/platform/types/EndpointError';
import * as SlowmodeCommands from '@app/features/slowmode/commands/SlowmodeCommands';
import Slowmode from '@app/features/slowmode/state/Slowmode';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const CHANNEL_ID = '1234567890123456789';
const RATE_LIMIT_PER_USER = 5;
const SLOWMODE_WINDOW_MS = RATE_LIMIT_PER_USER * 1000;
const T0 = Date.UTC(2026, 7, 20, 12, 0, 0);
function at(offsetMs: number): void {
vi.setSystemTime(T0 + offsetMs);
}
function shownRemainingMs(): number {
return Slowmode.getSlowmodeRemaining(CHANNEL_ID, RATE_LIMIT_PER_USER);
}
function shownCountdownSeconds(): number {
return Math.ceil(shownRemainingMs() / 1000);
}
function composerBlocksSend(): boolean {
return shownRemainingMs() > 0;
}
function serverTimestamp(offsetMs: number): string {
return new Date(T0 + offsetMs).toISOString();
}
function slowmodeRejection(retryAfterDecimalSeconds: number, retryAfterHeader: string): HttpError {
return new HttpError({
method: 'POST',
path: `/channels/${CHANNEL_ID}/messages`,
status: 400,
body: {code: APIErrorCodes.SLOWMODE_RATE_LIMITED, retry_after: retryAfterDecimalSeconds},
responseHeaders: {'retry-after': retryAfterHeader},
});
}
function applySlowmodeRejection(error: HttpError): number {
const retryAfterMs = SlowmodeCommands.clampSlowmodeRetryAfterMs(resolveRetryAfterMs(error));
SlowmodeCommands.updateSlowmodeRemaining(CHANNEL_ID, retryAfterMs);
return retryAfterMs;
}
describe('slowmode while an earlier send is still pending', () => {
beforeEach(() => {
vi.useFakeTimers();
at(0);
Slowmode.clearChannel(CHANNEL_ID);
});
afterEach(() => {
Slowmode.clearChannel(CHANNEL_ID);
vi.useRealTimers();
});
it('blocks the second message while the first one is still pending', () => {
at(0);
expect(composerBlocksSend()).toBe(false);
SlowmodeCommands.recordPendingMessageSend(CHANNEL_ID);
at(120);
expect(composerBlocksSend()).toBe(true);
expect(shownRemainingMs()).toBe(SLOWMODE_WINDOW_MS - 120);
});
it('anchors the window to the timestamp the server assigned the message', () => {
at(0);
SlowmodeCommands.recordPendingMessageSend(CHANNEL_ID);
at(450);
SlowmodeCommands.confirmMessageSend(CHANNEL_ID, serverTimestamp(200));
expect(Slowmode.getLastSendTimestamp(CHANNEL_ID)).toBe(T0 + 200);
expect(shownRemainingMs()).toBe(SLOWMODE_WINDOW_MS - 250);
});
it('never shows more than the channel setting across the reported ordering', () => {
const countdown: Array<{atMs: number; shownMs: number; shownSeconds: number}> = [];
const sample = (offsetMs: number): void => {
at(offsetMs);
countdown.push({atMs: offsetMs, shownMs: shownRemainingMs(), shownSeconds: shownCountdownSeconds()});
};
at(0);
SlowmodeCommands.recordPendingMessageSend(CHANNEL_ID);
sample(0);
sample(120);
sample(449);
at(450);
SlowmodeCommands.confirmMessageSend(CHANNEL_ID, serverTimestamp(200));
sample(450);
sample(2000);
sample(5199);
sample(5200);
sample(10200);
for (const entry of countdown) {
expect(entry.shownMs).toBeLessThanOrEqual(SLOWMODE_WINDOW_MS);
expect(entry.shownSeconds).toBeLessThanOrEqual(RATE_LIMIT_PER_USER);
}
for (let index = 1; index < countdown.length; index++) {
expect(countdown[index]!.shownSeconds).toBeLessThanOrEqual(countdown[index - 1]!.shownSeconds);
}
expect(countdown.at(-3)!.shownMs).toBeGreaterThan(0);
expect(countdown.at(-2)!.shownMs).toBe(0);
expect(countdown.at(-1)!.shownMs).toBe(0);
});
it('keeps a server slowmode rejection inside the channel setting', () => {
at(0);
SlowmodeCommands.recordPendingMessageSend(CHANNEL_ID);
at(450);
SlowmodeCommands.confirmMessageSend(CHANNEL_ID, serverTimestamp(200));
at(759);
const beforeRejection = shownRemainingMs();
at(760);
const storedMs = applySlowmodeRejection(slowmodeRejection(4.44, '5'));
expect(storedMs).toBe(4440);
expect(shownRemainingMs()).toBe(SLOWMODE_WINDOW_MS - 560);
expect(shownRemainingMs()).toBeLessThan(beforeRejection);
at(5200);
expect(shownRemainingMs()).toBe(0);
expect(composerBlocksSend()).toBe(false);
});
it('does not inflate the window when the rejection header carries milliseconds', () => {
at(0);
SlowmodeCommands.recordPendingMessageSend(CHANNEL_ID);
at(450);
SlowmodeCommands.confirmMessageSend(CHANNEL_ID, serverTimestamp(200));
at(760);
const storedMs = applySlowmodeRejection(slowmodeRejection(4.7, '4700'));
expect(storedMs).toBe(4700);
expect(shownRemainingMs()).toBeLessThanOrEqual(SLOWMODE_WINDOW_MS);
at(5460);
expect(shownRemainingMs()).toBe(0);
});
it('releases the window when the pending send never reaches the server', () => {
at(0);
const pendingSend = SlowmodeCommands.recordPendingMessageSend(CHANNEL_ID);
at(120);
expect(composerBlocksSend()).toBe(true);
at(300);
SlowmodeCommands.discardPendingMessageSend(CHANNEL_ID, pendingSend);
expect(Slowmode.getLastSendTimestamp(CHANNEL_ID)).toBeNull();
expect(composerBlocksSend()).toBe(false);
});
it('keeps a rejection window when the rejected send releases its own guess', () => {
at(0);
const pendingSend = SlowmodeCommands.recordPendingMessageSend(CHANNEL_ID);
at(760);
applySlowmodeRejection(slowmodeRejection(4.44, '5'));
SlowmodeCommands.discardPendingMessageSend(CHANNEL_ID, pendingSend);
expect(Slowmode.getLastSendTimestamp(CHANNEL_ID)).toBeNull();
expect(shownRemainingMs()).toBe(4440);
});
it('ignores an anchor from a clock that runs behind the server', () => {
at(0);
SlowmodeCommands.recordPendingMessageSend(CHANNEL_ID);
at(450);
SlowmodeCommands.confirmMessageSend(CHANNEL_ID, serverTimestamp(30_000));
expect(shownRemainingMs()).toBeLessThanOrEqual(SLOWMODE_WINDOW_MS);
at(5450);
expect(shownRemainingMs()).toBe(0);
});
});
describe('clock skew on the send anchor', () => {
const CHANNEL_ID = '900000000000000001';
const RATE_LIMIT_SECONDS = 5;
beforeEach(() => {
vi.useFakeTimers();
Slowmode.clearChannel(CHANNEL_ID);
});
afterEach(() => {
Slowmode.clearChannel(CHANNEL_ID);
vi.useRealTimers();
});
const remainingAfterAck = (clientAheadMs: number): number => {
vi.setSystemTime(new Date(1_000_000));
const pending = SlowmodeCommands.recordPendingMessageSend(CHANNEL_ID);
const serverAcceptedAt = new Date(1_000_000 - clientAheadMs).toISOString();
vi.setSystemTime(new Date(1_000_450));
SlowmodeCommands.confirmMessageSend(CHANNEL_ID, serverAcceptedAt, pending);
return Slowmode.getSlowmodeRemaining(CHANNEL_ID, RATE_LIMIT_SECONDS);
};
it('does not shorten the window when the client clock runs ahead of the server', () => {
for (const aheadMs of [0, 250, 1_000, 2_500, 4_800, 30_000, 3_600_000]) {
expect(remainingAfterAck(aheadMs)).toBe(4_550);
}
});
it('never lets the local guard reach zero while the window is live', () => {
for (const aheadMs of [4_800, 30_000, 3_600_000]) {
expect(remainingAfterAck(aheadMs)).toBeGreaterThan(0);
}
});
});
@@ -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(),
},
});
}