fix(matrix): upload retry/backoff, robust MatrixError, typed m.direct, reliable presence on unload

- uploadContent: bounded retry (max 3) reusing rateLimitedActions' capped
  exponential backoff; retries only transient failures (network/429/5xx), never 4xx.
- Robust MatrixError construction from UploadResponse / unknown error shapes.
- addRoomIdToMDirect/removeRoomIdFromMDirect: drop `as any`, use typed
  EventType.Direct + MDirectContent.
- usePresenceUpdater: keep fetch({keepalive}) for the unload offline-presence
  update (sendBeacon can't set the auth header) and log redacted warnings instead
  of silently swallowing presence errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 08:22:00 -04:00
parent b7e1f89c1d
commit d2946c00ce
2 changed files with 110 additions and 29 deletions
+92 -25
View File
@@ -5,6 +5,7 @@ import {
} from 'browser-encrypt-attachment';
import {
EventTimeline,
EventType,
MatrixClient,
MatrixError,
MatrixEvent,
@@ -15,7 +16,7 @@ import {
} from 'matrix-js-sdk';
import to from 'await-to-js';
import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { MDirectContent } from '../../types/matrix/accountData';
import { getStateEvent } from './room';
import { Membership, StateEvent } from '../../types/matrix/room';
@@ -145,6 +146,42 @@ export type ContentUploadOptions = {
onError: (error: MatrixError) => void;
};
// Build a MatrixError defensively from an unexpected upload response.
// MatrixError's constructor expects an IErrorJson ({ errcode?, error?, ... }), not a
// raw UploadResponse, so we guard the shape and only forward string fields it understands.
const matrixErrorFromUploadResponse = (data: UploadResponse): MatrixError => {
const errorJson: { errcode?: string; error?: string } = {};
const maybe = data as Partial<{ errcode: unknown; error: unknown }>;
if (typeof maybe.errcode === 'string') errorJson.errcode = maybe.errcode;
if (typeof maybe.error === 'string') errorJson.error = maybe.error;
if (!errorJson.error) errorJson.error = 'Upload failed: missing content_uri in response';
return new MatrixError(errorJson);
};
const matrixErrorFromUnknown = (e: unknown): MatrixError => {
if (e instanceof MatrixError) return e;
const err = e as Partial<{ message: unknown; errcode: unknown }> | null | undefined;
const error = typeof err?.message === 'string' ? err.message : 'Upload failed';
const errcode = typeof err?.errcode === 'string' ? err.errcode : undefined;
return new MatrixError({ error, errcode });
};
// HTTP statuses that should not be retried — client errors are deterministic
// (e.g. 413 payload too large, 400 bad request, 401/403 auth) and won't succeed on retry.
const isRetryableUploadError = (e: unknown): boolean => {
if (e instanceof MatrixError) {
const status = e.httpStatus;
// No status => network/transport failure (transient): retry.
if (typeof status !== 'number') return true;
// Retry on rate-limiting and server-side (5xx) errors only.
return status === 429 || status >= 500;
}
// Non-Matrix errors are typically network/transport failures: retry.
return true;
};
const UPLOAD_MAX_RETRY_COUNT = 3;
export const uploadContent = async (
mx: MatrixClient,
file: TUploadContent,
@@ -152,23 +189,53 @@ export const uploadContent = async (
) => {
const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options;
const uploadPromise = mx.uploadContent(file, {
name,
type: fileType,
includeFilename: !hideFilename,
progressHandler: onProgress,
});
onPromise?.(uploadPromise);
try {
const data = await uploadPromise;
const mxc = data.content_uri;
if (mxc) onSuccess(mxc);
else onError(new MatrixError(data));
} catch (e: any) {
const error = typeof e?.message === 'string' ? e.message : undefined;
const errcode = typeof e?.name === 'string' ? e.message : undefined;
onError(new MatrixError({ error, errcode }));
const sleepForMs = (ms: number) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
let lastError: MatrixError | undefined;
for (let retryCount = 0; retryCount <= UPLOAD_MAX_RETRY_COUNT; retryCount += 1) {
const uploadPromise = mx.uploadContent(file, {
name,
type: fileType,
includeFilename: !hideFilename,
progressHandler: onProgress,
});
onPromise?.(uploadPromise);
try {
// eslint-disable-next-line no-await-in-loop
const data = await uploadPromise;
const mxc = data.content_uri;
if (mxc) {
onSuccess(mxc);
return;
}
// Missing content_uri is not a transient failure — fail immediately.
onError(matrixErrorFromUploadResponse(data));
return;
} catch (e: unknown) {
lastError = matrixErrorFromUnknown(e);
if (retryCount === UPLOAD_MAX_RETRY_COUNT || !isRetryableUploadError(e)) {
onError(lastError);
return;
}
// Respect server Retry-After header; fall back to capped exponential backoff,
// mirroring rateLimitedActions (min(1000 * 2^retryCount, 30_000)ms).
const waitMS =
(e instanceof MatrixError ? e.getRetryAfterMs() : null) ??
Math.min(1000 * 2 ** retryCount, 30_000);
// eslint-disable-next-line no-await-in-loop
await sleepForMs(waitMS);
}
}
// Unreachable in practice, but keeps onError guaranteed if the loop exits.
if (lastError) onError(lastError);
};
export const matrixEventByRecency = (m1: MatrixEvent, m2: MatrixEvent) => m2.getTs() - m1.getTs();
@@ -230,11 +297,11 @@ export const addRoomIdToMDirect = async (
roomId: string,
userId: string,
): Promise<void> => {
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
let userIdToRoomIds: Record<string, string[]> = {};
const mDirectsEvent = mx.getAccountData(EventType.Direct);
let userIdToRoomIds: MDirectContent = {};
if (typeof mDirectsEvent !== 'undefined')
userIdToRoomIds = structuredClone(mDirectsEvent.getContent());
userIdToRoomIds = structuredClone(mDirectsEvent.getContent<MDirectContent>());
// remove it from the lists of any others users
// (it can only be a DM room for one person)
@@ -255,15 +322,15 @@ export const addRoomIdToMDirect = async (
}
userIdToRoomIds[userId] = roomIds;
await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any);
await mx.setAccountData(EventType.Direct, userIdToRoomIds);
};
export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise<void> => {
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
let userIdToRoomIds: Record<string, string[]> = {};
const mDirectsEvent = mx.getAccountData(EventType.Direct);
let userIdToRoomIds: MDirectContent = {};
if (typeof mDirectsEvent !== 'undefined')
userIdToRoomIds = structuredClone(mDirectsEvent.getContent());
userIdToRoomIds = structuredClone(mDirectsEvent.getContent<MDirectContent>());
Object.keys(userIdToRoomIds).forEach((targetUserId) => {
const roomIds = userIdToRoomIds[targetUserId];
@@ -273,7 +340,7 @@ export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string):
}
});
await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any);
await mx.setAccountData(EventType.Direct, userIdToRoomIds);
};
export const mxcUrlToHttp = (