mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(app): submit message edits in background
This commit is contained in:
1 parent
c0048504db
commit
8e17970632
6 files changed
+160
-214
No files matched your search
@@ -200,18 +200,15 @@ export const UserMessage = observer(() => {
|
||||
finishEditing();
|
||||
return;
|
||||
}
|
||||
MessageCommands.edit(
|
||||
finishEditing();
|
||||
void MessageCommands.edit(
|
||||
channel.id,
|
||||
message.id,
|
||||
'',
|
||||
undefined,
|
||||
message._allowedMentions,
|
||||
buildExistingAttachmentEditReferences(message),
|
||||
).then((result) => {
|
||||
if (result) {
|
||||
finishEditing();
|
||||
}
|
||||
});
|
||||
);
|
||||
return;
|
||||
}
|
||||
handleDelete();
|
||||
@@ -220,11 +217,8 @@ export const UserMessage = observer(() => {
|
||||
if (checkCustomEmojiAvailability(content)) {
|
||||
return;
|
||||
}
|
||||
MessageCommands.edit(channel.id, message.id, content, undefined, message._allowedMentions).then((result) => {
|
||||
if (result) {
|
||||
finishEditing();
|
||||
}
|
||||
});
|
||||
finishEditing();
|
||||
void MessageCommands.edit(channel.id, message.id, content, undefined, message._allowedMentions);
|
||||
},
|
||||
[
|
||||
channel.id,
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
import {resolveMessagePageState} from '@app/features/messaging/commands/MessagePageStateMachine';
|
||||
import {MessageDeleteFailedModal} from '@app/features/messaging/components/alerts/MessageDeleteFailedModal';
|
||||
import {MessageDeleteTooQuickModal} from '@app/features/messaging/components/alerts/MessageDeleteTooQuickModal';
|
||||
import {MessageEditFailedModal} from '@app/features/messaging/components/alerts/MessageEditFailedModal';
|
||||
import {MessageEditTooQuickModal} from '@app/features/messaging/components/alerts/MessageEditTooQuickModal';
|
||||
import type {Message as MessageModel} from '@app/features/messaging/models/MessagingMessage';
|
||||
import type {JumpOptions} from '@app/features/messaging/state/ChannelMessages';
|
||||
import MessageEdit from '@app/features/messaging/state/MessageEdit';
|
||||
@@ -34,8 +36,10 @@ import {
|
||||
import {
|
||||
type ApiAttachmentMetadata,
|
||||
type ApiMessageEditAttachmentMetadata,
|
||||
buildMessageEditRequest,
|
||||
normalizeMessageContent,
|
||||
} from '@app/features/messaging/utils/MessageRequestUtils';
|
||||
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
|
||||
import * as IARCommands from '@app/features/moderation/commands/IARCommands';
|
||||
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
@@ -80,6 +84,8 @@ const ALSO_REPORT_TO_SAFETY_TEAM_DESCRIPTOR = msg({
|
||||
'Toggle-switch label in the moderator delete-message confirmation dialog. When enabled, the message is reported (category: other) before being deleted. {productName} is the product name (e.g., Fluxer).',
|
||||
});
|
||||
const logger = new Logger('MessageCommands');
|
||||
const MESSAGE_EDIT_MAX_RETRIES = 5;
|
||||
const MESSAGE_EDIT_TIMEOUT_MS = 30_000;
|
||||
const pendingDeletePromises = new Map<string, Promise<void>>();
|
||||
const pendingFetchPromises = new Map<string, Promise<Array<WireMessage>>>();
|
||||
|
||||
@@ -601,7 +607,42 @@ function showDeleteFailureModal(error: unknown, messageId: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
export function edit(
|
||||
function showEditFailureModal(error: unknown): void {
|
||||
if (error instanceof HttpError) {
|
||||
const errorCode = failureCode(error);
|
||||
if (error.status === 429) {
|
||||
const retryAfterMs = resolveRetryAfterMs(error);
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<MessageEditTooQuickModal
|
||||
retryAfter={retryAfterMs === null ? undefined : Math.ceil(retryAfterMs / 1000)}
|
||||
data-flx="messaging.message-commands.message-edit-too-quick-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (error.status === 403 && errorCode === APIErrorCodes.FEATURE_TEMPORARILY_DISABLED) {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<FeatureTemporarilyDisabledModal data-flx="messaging.message-commands.message-edit-feature-temporarily-disabled-modal" />
|
||||
)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (errorCode === APIErrorCodes.CONTENT_BLOCKED) {
|
||||
void import('@app/features/auth/components/ContentBlockedHandler').then((module) =>
|
||||
module.showContentBlockedModal(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
ModalCommands.push(
|
||||
modal(() => <MessageEditFailedModal data-flx="messaging.message-commands.message-edit-failed-modal" />),
|
||||
);
|
||||
}
|
||||
|
||||
export async function edit(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
content?: string,
|
||||
@@ -609,31 +650,22 @@ export function edit(
|
||||
allowedMentions?: AllowedMentions,
|
||||
attachments?: Array<ApiMessageEditAttachmentMetadata>,
|
||||
): Promise<WireMessage | null> {
|
||||
return new Promise<WireMessage | null>((resolve) => {
|
||||
logger.debug(`Enqueueing edit for message ${messageId} in channel ${channelId}`);
|
||||
MessageQueue.enqueue(
|
||||
{
|
||||
type: 'edit',
|
||||
channelId,
|
||||
messageId,
|
||||
content,
|
||||
allowedMentions,
|
||||
flags,
|
||||
attachments,
|
||||
},
|
||||
(result, error) => {
|
||||
if (result?.body) {
|
||||
logger.debug(`Message edited successfully: ${messageId} in channel ${channelId}`);
|
||||
resolve(result.body);
|
||||
} else {
|
||||
if (error) {
|
||||
logger.debug(`Message edit failed: ${messageId} in channel ${channelId}`, error);
|
||||
}
|
||||
resolve(null);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
logger.debug(`Editing message ${messageId} in channel ${channelId}`);
|
||||
try {
|
||||
const response = await http.patch<WireMessage>(Endpoints.CHANNEL_MESSAGE(channelId, messageId), {
|
||||
body: buildMessageEditRequest({content, flags, allowedMentions, attachments}),
|
||||
mode: 'auto-retry',
|
||||
retries: MESSAGE_EDIT_MAX_RETRIES,
|
||||
timeoutMs: MESSAGE_EDIT_TIMEOUT_MS,
|
||||
suppressContentBlockedModal: true,
|
||||
});
|
||||
logger.debug(`Message edited successfully: ${messageId} in channel ${channelId}`);
|
||||
return response.body ?? null;
|
||||
} catch (error) {
|
||||
logger.error(`Message edit failed: ${messageId} in channel ${channelId}`, error);
|
||||
showEditFailureModal(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(channelId: string, messageId: string): Promise<void> {
|
||||
|
||||
@@ -481,18 +481,15 @@ export const useTextareaSubmit = ({
|
||||
finishMobileEdit();
|
||||
return;
|
||||
}
|
||||
MessageCommands.edit(
|
||||
finishMobileEdit();
|
||||
void MessageCommands.edit(
|
||||
channelId,
|
||||
editingMessage.id,
|
||||
'',
|
||||
undefined,
|
||||
editingMessage._allowedMentions,
|
||||
buildExistingAttachmentEditReferences(editingMessage),
|
||||
).then((result) => {
|
||||
if (result) {
|
||||
finishMobileEdit();
|
||||
}
|
||||
});
|
||||
);
|
||||
return;
|
||||
}
|
||||
MessageCommands.showDeleteConfirmation(i18n, {
|
||||
@@ -506,17 +503,14 @@ export const useTextareaSubmit = ({
|
||||
if (checkCustomEmojiAvailability(resolvedContent)) {
|
||||
return;
|
||||
}
|
||||
MessageCommands.edit(
|
||||
finishMobileEdit();
|
||||
void MessageCommands.edit(
|
||||
channelId,
|
||||
editingMessage.id,
|
||||
resolvedContent,
|
||||
undefined,
|
||||
editingMessage._allowedMentions,
|
||||
).then((result) => {
|
||||
if (result) {
|
||||
finishMobileEdit();
|
||||
}
|
||||
});
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!hasVisibleMessageContent(resolvedContent) && uploadAttachmentsLength === 0 && !hasPendingSticker) {
|
||||
|
||||
@@ -10,15 +10,11 @@ import * as DraftCommands from '@app/features/messaging/commands/DraftCommands';
|
||||
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
|
||||
import {AttachmentUploadConnectivityModal} from '@app/features/messaging/components/alerts/AttachmentUploadConnectivityModal';
|
||||
import {FileSizeTooLargeModal} from '@app/features/messaging/components/alerts/FileSizeTooLargeModal';
|
||||
import {MessageEditFailedModal} from '@app/features/messaging/components/alerts/MessageEditFailedModal';
|
||||
import {MessageEditTooQuickModal} from '@app/features/messaging/components/alerts/MessageEditTooQuickModal';
|
||||
import {MessageSendFailedModal} from '@app/features/messaging/components/alerts/MessageSendFailedModal';
|
||||
import {MessageSendTooQuickModal} from '@app/features/messaging/components/alerts/MessageSendTooQuickModal';
|
||||
import {
|
||||
type MessageLocalSendRateLimitState,
|
||||
type MessageQueueRequestOutcomeStatus,
|
||||
resolveMessageLocalSendRateLimitDecision,
|
||||
resolveMessageQueuePayloadRouteDecision,
|
||||
resolveMessageQueueRequestOutcomeDecision,
|
||||
resolveMessageQueueSendExecutionDecision,
|
||||
} from '@app/features/messaging/state/MessageQueueStateMachine';
|
||||
@@ -33,11 +29,8 @@ import {exceedsMultipartFallbackRequestSize} from '@app/features/messaging/utils
|
||||
import {prepareAttachmentsForNonce} from '@app/features/messaging/utils/MessageAttachmentUtils';
|
||||
import {
|
||||
type ApiAttachmentMetadata,
|
||||
type ApiMessageEditAttachmentMetadata,
|
||||
buildMessageCreateRequest,
|
||||
type MessageCreateRequest,
|
||||
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';
|
||||
@@ -120,17 +113,7 @@ interface SendMessagePayload extends BaseMessagePayload {
|
||||
tts?: boolean;
|
||||
}
|
||||
|
||||
interface EditMessagePayload extends BaseMessagePayload {
|
||||
type: 'edit';
|
||||
messageId: string;
|
||||
rateLimitRetryCount?: number;
|
||||
content?: string;
|
||||
allowedMentions?: AllowedMentions;
|
||||
flags?: number;
|
||||
attachments?: Array<ApiMessageEditAttachmentMetadata>;
|
||||
}
|
||||
|
||||
export type MessageQueuePayload = SendMessagePayload | EditMessagePayload;
|
||||
export type MessageQueuePayload = SendMessagePayload;
|
||||
type MessageQueueCompletion<TResult> = {
|
||||
retry: RetryError | null;
|
||||
result?: TResult;
|
||||
@@ -260,11 +243,6 @@ function isRateLimitError(error: HttpError): boolean {
|
||||
return !isSlowmodeError(error);
|
||||
}
|
||||
|
||||
function getRequestErrorOutcomeStatus(error: HttpError): MessageQueueRequestOutcomeStatus {
|
||||
if (isRateLimitError(error)) return 'rateLimit';
|
||||
return 'failure';
|
||||
}
|
||||
|
||||
function isSlowmodeError(error: HttpError): boolean {
|
||||
if (error.status !== 400 && error.status !== 429) return false;
|
||||
const body = getApiErrorBody(error);
|
||||
@@ -351,13 +329,7 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
|
||||
}
|
||||
const error = new Error(`Message queue capacity of ${this.maxSize} entries was reached`);
|
||||
logger.error('Rejected message queue entry because capacity was reached', error);
|
||||
if (message.type === 'send') {
|
||||
this.handleSendError(message.channelId, message.nonce, error, i18n, message.hasAttachments);
|
||||
} else {
|
||||
ModalCommands.push(
|
||||
modal(() => <MessageEditFailedModal data-flx="messaging.message-queue.message-edit-failed-modal--capacity" />),
|
||||
);
|
||||
}
|
||||
this.handleSendError(message.channelId, message.nonce, error, i18n, message.hasAttachments);
|
||||
try {
|
||||
success(undefined, error);
|
||||
} catch (callbackError) {
|
||||
@@ -407,19 +379,7 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
|
||||
message: MessageQueuePayload,
|
||||
completed: (err: RetryError | null, result?: RestResponse<Message>, error?: unknown) => void,
|
||||
): Promise<unknown> | undefined {
|
||||
const route = resolveMessageQueuePayloadRouteDecision({
|
||||
payloadType: (message as {type?: string}).type,
|
||||
});
|
||||
switch (route.type) {
|
||||
case 'send':
|
||||
return this.handleSend(message as SendMessagePayload, completed);
|
||||
case 'edit':
|
||||
return this.handleEdit(message as EditMessagePayload, completed);
|
||||
case 'unknown':
|
||||
logger.error('Unknown message type, completing with null');
|
||||
completed(null, undefined, new Error('Unknown message queue payload'));
|
||||
return undefined;
|
||||
}
|
||||
return this.handleSend(message, completed);
|
||||
}
|
||||
|
||||
private consumeLocalSendAllowance(channelId: string): boolean {
|
||||
@@ -1429,127 +1389,6 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async handleEdit(
|
||||
payload: EditMessagePayload,
|
||||
completed: (err: RetryError | null, result?: RestResponse<Message>, error?: unknown) => void,
|
||||
): Promise<void> {
|
||||
const {channelId, messageId, content, allowedMentions, flags, attachments} = payload;
|
||||
const abortController = new AbortController();
|
||||
this.abortControllers.set(messageId, abortController);
|
||||
try {
|
||||
logger.debug(`Editing message ${messageId} in channel ${channelId}`);
|
||||
const body = this.buildEditRequestBody(content, allowedMentions, flags, attachments);
|
||||
const response = await http.patch<Message>(Endpoints.CHANNEL_MESSAGE(channelId, messageId), {
|
||||
body,
|
||||
signal: abortController.signal,
|
||||
suppressContentBlockedModal: true,
|
||||
});
|
||||
logger.debug(`Successfully edited message ${messageId} in channel ${channelId}`);
|
||||
completed(null, response);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to edit message ${messageId} in channel ${channelId}:`, error);
|
||||
if (!(error instanceof HttpError)) {
|
||||
ModalCommands.push(
|
||||
modal(() => <MessageEditFailedModal data-flx="messaging.message-queue.message-edit-failed-modal--request" />),
|
||||
);
|
||||
completed(null, undefined, error);
|
||||
return;
|
||||
}
|
||||
const responseErr = error;
|
||||
const outcomeDecision = resolveMessageQueueRequestOutcomeDecision({
|
||||
status: getRequestErrorOutcomeStatus(responseErr),
|
||||
});
|
||||
switch (outcomeDecision.type) {
|
||||
case 'retryRateLimit':
|
||||
this.handleEditRateLimit(payload, responseErr, completed);
|
||||
break;
|
||||
case 'completeFailure':
|
||||
case 'completeSuccess':
|
||||
this.showEditErrorModal(responseErr);
|
||||
completed(null, undefined, responseErr);
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
this.abortControllers.delete(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
private buildEditRequestBody(
|
||||
content?: string,
|
||||
allowedMentions?: AllowedMentions,
|
||||
flags?: number,
|
||||
attachments?: Array<ApiMessageEditAttachmentMetadata>,
|
||||
): MessageEditRequest {
|
||||
const body: MessageEditRequest = {};
|
||||
if (content !== undefined) {
|
||||
body.content = normalizeMessageEditContent(content);
|
||||
}
|
||||
if (allowedMentions !== undefined) {
|
||||
body.allowed_mentions = allowedMentions;
|
||||
}
|
||||
if (flags !== undefined) {
|
||||
body.flags = flags;
|
||||
}
|
||||
if (attachments !== undefined) {
|
||||
body.attachments = attachments;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private handleEditRateLimit(
|
||||
payload: EditMessagePayload,
|
||||
error: HttpError,
|
||||
completed: (err: RetryError | null, result?: RestResponse<Message>, error?: unknown) => void,
|
||||
): void {
|
||||
const retry = resolveMessageRateLimitRetry(error);
|
||||
const retryCount = payload.rateLimitRetryCount === undefined ? 0 : payload.rateLimitRetryCount;
|
||||
if (retry.automaticRetryDelayMs !== null && retryCount < MESSAGE_SEND_RATE_LIMIT_MAX_AUTOMATIC_RETRIES) {
|
||||
payload.rateLimitRetryCount = retryCount + 1;
|
||||
completed({retryAfter: retry.automaticRetryDelayMs}, undefined, error);
|
||||
return;
|
||||
}
|
||||
completed(null, undefined, error);
|
||||
this.handleEditRateLimitError(retryAfterMsToWholeSeconds(retry.retryAfterMs));
|
||||
}
|
||||
|
||||
private showEditErrorModal(error: HttpError): void {
|
||||
if (isFeatureDisabledError(error)) {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<FeatureTemporarilyDisabledModal data-flx="messaging.message-queue.feature-temporarily-disabled-modal--2" />
|
||||
)),
|
||||
);
|
||||
} else if (getApiErrorBody(error)?.code === APIErrorCodes.CONTENT_BLOCKED) {
|
||||
void import('@app/features/auth/components/ContentBlockedHandler').then((m) => m.showContentBlockedModal());
|
||||
} else {
|
||||
ModalCommands.push(
|
||||
modal(() => <MessageEditFailedModal data-flx="messaging.message-queue.message-edit-failed-modal" />),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleEditRateLimitError(retryAfter: number | null, onRetry?: () => void): void {
|
||||
ModalCommands.push(
|
||||
modal(() => {
|
||||
if (retryAfter === null) {
|
||||
return (
|
||||
<MessageEditTooQuickModal
|
||||
onRetry={onRetry}
|
||||
data-flx="messaging.message-queue.message-edit-too-quick-modal"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MessageEditTooQuickModal
|
||||
retryAfter={retryAfter}
|
||||
onRetry={onRetry}
|
||||
data-flx="messaging.message-queue.message-edit-too-quick-modal"
|
||||
/>
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new MessageQueue();
|
||||
@@ -57,6 +57,13 @@ export interface MessageEditRequest {
|
||||
flags?: number;
|
||||
}
|
||||
|
||||
export interface MessageEditPayload {
|
||||
content?: string;
|
||||
attachments?: Array<ApiMessageEditAttachmentMetadata>;
|
||||
allowedMentions?: AllowedMentions;
|
||||
flags?: number;
|
||||
}
|
||||
|
||||
export interface MessageCreatePayload {
|
||||
content?: string | null;
|
||||
nonce?: string;
|
||||
@@ -121,6 +128,24 @@ export function buildMessageCreateRequest(payload: MessageCreatePayload): Messag
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
export function buildMessageEditRequest(payload: MessageEditPayload): MessageEditRequest {
|
||||
const {content, attachments, allowedMentions, flags} = payload;
|
||||
const requestBody: MessageEditRequest = {};
|
||||
if (content !== undefined) {
|
||||
requestBody.content = normalizeMessageEditContent(content);
|
||||
}
|
||||
if (attachments !== undefined) {
|
||||
requestBody.attachments = attachments;
|
||||
}
|
||||
if (allowedMentions !== undefined) {
|
||||
requestBody.allowed_mentions = allowedMentions;
|
||||
}
|
||||
if (flags !== undefined) {
|
||||
requestBody.flags = flags;
|
||||
}
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
const isSilentMessage = (content: string): boolean => {
|
||||
return content.startsWith('@silent ');
|
||||
};
|
||||
|
||||
@@ -78,6 +78,63 @@ type AttemptDecision =
|
||||
| {next: 'retry-after'; delayMs: number; mode: 'backoff' | 'fixed'}
|
||||
| {next: 'fail'; error: unknown};
|
||||
|
||||
interface OnlineWaiter {
|
||||
resolve: () => void;
|
||||
signal: AbortSignal | undefined;
|
||||
onAbort: () => void;
|
||||
}
|
||||
|
||||
const onlineWaiters = new Set<OnlineWaiter>();
|
||||
let onlineListenerActive = false;
|
||||
|
||||
function createRequestAbortError(): DOMException {
|
||||
return new DOMException('Request aborted', 'AbortError');
|
||||
}
|
||||
|
||||
function removeOnlineListener(): void {
|
||||
if (!onlineListenerActive) return;
|
||||
window.removeEventListener('online', resolveOnlineWaiters);
|
||||
onlineListenerActive = false;
|
||||
}
|
||||
|
||||
function releaseOnlineWaiter(waiter: OnlineWaiter): boolean {
|
||||
if (!onlineWaiters.delete(waiter)) return false;
|
||||
waiter.signal?.removeEventListener('abort', waiter.onAbort);
|
||||
if (onlineWaiters.size === 0) removeOnlineListener();
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveOnlineWaiters(): void {
|
||||
const pending = Array.from(onlineWaiters);
|
||||
onlineWaiters.clear();
|
||||
removeOnlineListener();
|
||||
for (const waiter of pending) {
|
||||
waiter.signal?.removeEventListener('abort', waiter.onAbort);
|
||||
waiter.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function waitUntilOnline(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) return Promise.reject(createRequestAbortError());
|
||||
if (navigator.onLine) return Promise.resolve();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const waiter: OnlineWaiter = {
|
||||
resolve,
|
||||
signal,
|
||||
onAbort: () => {
|
||||
if (releaseOnlineWaiter(waiter)) reject(createRequestAbortError());
|
||||
},
|
||||
};
|
||||
onlineWaiters.add(waiter);
|
||||
if (signal) signal.addEventListener('abort', waiter.onAbort, {once: true});
|
||||
if (!onlineListenerActive) {
|
||||
window.addEventListener('online', resolveOnlineWaiters);
|
||||
onlineListenerActive = true;
|
||||
}
|
||||
if (navigator.onLine) queueMicrotask(resolveOnlineWaiters);
|
||||
});
|
||||
}
|
||||
|
||||
export class RestClient {
|
||||
private readonly state: RuntimeState = {
|
||||
baseUrl: '/api',
|
||||
@@ -216,6 +273,7 @@ async function runRetryLoop<T>(
|
||||
attempt: number,
|
||||
): Promise<RestResponse<T>> {
|
||||
const plan = composePlan(state, method, path, options, sudoApplied);
|
||||
if (plan.retries > 0) await waitUntilOnline(plan.signal);
|
||||
const pacingHit = consultPacing(state.pacing, plan.rateLimitKey);
|
||||
if (pacingHit) {
|
||||
if (plan.mode === 'auto-retry') {
|
||||
@@ -240,7 +298,11 @@ async function runRetryLoop<T>(
|
||||
return finalizeAfterRetriesExhausted<T>(state, plan, outcome);
|
||||
}
|
||||
const wait = decision.mode === 'backoff' ? computeBackoffMs(attempt) : decision.delayMs;
|
||||
await delay(wait, plan.signal);
|
||||
if (outcome.status === 'transport-error' && !navigator.onLine) {
|
||||
await waitUntilOnline(plan.signal);
|
||||
} else {
|
||||
await delay(wait, plan.signal);
|
||||
}
|
||||
return runRetryLoop<T>(state, method, path, options, sudoApplied, attempt + 1);
|
||||
}
|
||||
case 'fail':
|
||||
|
||||
Reference in new issue
Block a user