mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
fix(slowmode): stop the local cooldown from outgrowing the channel setting (#1795)
This commit is contained in:
1 parent
8f4f9a8601
commit
631bc2307a
15 files changed
+497
-147
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user