mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-03 05:10:25 +03:00
fix(slowmode): stop the local cooldown from outgrowing the channel setting (#1795)
This commit is contained in:
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user