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:
@@ -23,6 +23,13 @@ export function usePresenceUpdater() {
|
|||||||
const readStatus = () =>
|
const readStatus = () =>
|
||||||
userId ? (localStorage.getItem(`lotus-status-msg-${userId}`) ?? '') : '';
|
userId ? (localStorage.getItem(`lotus-status-msg-${userId}`) ?? '') : '';
|
||||||
|
|
||||||
|
// Log presence failures without leaking PII (user id, token, status message).
|
||||||
|
const warnPresenceFailure = (presence: string, err: unknown) => {
|
||||||
|
const reason =
|
||||||
|
err instanceof Error ? err.message : typeof err === 'string' ? err : 'unknown error';
|
||||||
|
console.warn(`Failed to set presence to "${presence}":`, reason);
|
||||||
|
};
|
||||||
|
|
||||||
const setOnline = () => {
|
const setOnline = () => {
|
||||||
const status = readStatus();
|
const status = readStatus();
|
||||||
return mx
|
return mx
|
||||||
@@ -30,7 +37,7 @@ export function usePresenceUpdater() {
|
|||||||
presence: 'online',
|
presence: 'online',
|
||||||
...(status ? { status_msg: status } : {}),
|
...(status ? { status_msg: status } : {}),
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch((err) => warnPresenceFailure('online', err));
|
||||||
};
|
};
|
||||||
const setUnavailable = (statusMsg?: string) => {
|
const setUnavailable = (statusMsg?: string) => {
|
||||||
const status = readStatus();
|
const status = readStatus();
|
||||||
@@ -39,10 +46,12 @@ export function usePresenceUpdater() {
|
|||||||
presence: 'unavailable',
|
presence: 'unavailable',
|
||||||
...(statusMsg ? { status_msg: statusMsg } : status ? { status_msg: status } : {}),
|
...(statusMsg ? { status_msg: statusMsg } : status ? { status_msg: status } : {}),
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch((err) => warnPresenceFailure('unavailable', err));
|
||||||
};
|
};
|
||||||
const setOffline = () =>
|
const setOffline = () =>
|
||||||
mx.setPresence({ presence: 'offline', status_msg: '' }).catch(() => undefined);
|
mx
|
||||||
|
.setPresence({ presence: 'offline', status_msg: '' })
|
||||||
|
.catch((err) => warnPresenceFailure('offline', err));
|
||||||
|
|
||||||
// Manual presence overrides — no activity tracking needed.
|
// Manual presence overrides — no activity tracking needed.
|
||||||
if (hidePresence || presenceStatus === 'invisible') {
|
if (hidePresence || presenceStatus === 'invisible') {
|
||||||
@@ -100,6 +109,11 @@ export function usePresenceUpdater() {
|
|||||||
const baseUrl = mx.getHomeserverUrl();
|
const baseUrl = mx.getHomeserverUrl();
|
||||||
if (!userId || !token || !baseUrl) return;
|
if (!userId || !token || !baseUrl) return;
|
||||||
|
|
||||||
|
// Reliable delivery during page teardown: navigator.sendBeacon cannot set the
|
||||||
|
// Authorization header required by the authenticated Matrix presence endpoint, so
|
||||||
|
// it isn't usable here. fetch(..., { keepalive: true }) lets the request outlive the
|
||||||
|
// page and is the correct mechanism for an authed endpoint. (keepalive bodies are
|
||||||
|
// capped at 64KB, which this tiny payload is well under.)
|
||||||
fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, {
|
fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -108,7 +122,7 @@ export function usePresenceUpdater() {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({ presence: 'offline' }),
|
body: JSON.stringify({ presence: 'offline' }),
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
}).catch(() => undefined);
|
}).catch((err) => warnPresenceFailure('offline (pagehide)', err));
|
||||||
};
|
};
|
||||||
|
|
||||||
setOnline();
|
setOnline();
|
||||||
|
|||||||
+82
-15
@@ -5,6 +5,7 @@ import {
|
|||||||
} from 'browser-encrypt-attachment';
|
} from 'browser-encrypt-attachment';
|
||||||
import {
|
import {
|
||||||
EventTimeline,
|
EventTimeline,
|
||||||
|
EventType,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
MatrixError,
|
MatrixError,
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
@@ -15,7 +16,7 @@ import {
|
|||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import to from 'await-to-js';
|
import to from 'await-to-js';
|
||||||
import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common';
|
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 { getStateEvent } from './room';
|
||||||
import { Membership, StateEvent } from '../../types/matrix/room';
|
import { Membership, StateEvent } from '../../types/matrix/room';
|
||||||
|
|
||||||
@@ -145,6 +146,42 @@ export type ContentUploadOptions = {
|
|||||||
onError: (error: MatrixError) => void;
|
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 (
|
export const uploadContent = async (
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
file: TUploadContent,
|
file: TUploadContent,
|
||||||
@@ -152,6 +189,14 @@ export const uploadContent = async (
|
|||||||
) => {
|
) => {
|
||||||
const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options;
|
const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options;
|
||||||
|
|
||||||
|
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, {
|
const uploadPromise = mx.uploadContent(file, {
|
||||||
name,
|
name,
|
||||||
type: fileType,
|
type: fileType,
|
||||||
@@ -159,16 +204,38 @@ export const uploadContent = async (
|
|||||||
progressHandler: onProgress,
|
progressHandler: onProgress,
|
||||||
});
|
});
|
||||||
onPromise?.(uploadPromise);
|
onPromise?.(uploadPromise);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
const data = await uploadPromise;
|
const data = await uploadPromise;
|
||||||
const mxc = data.content_uri;
|
const mxc = data.content_uri;
|
||||||
if (mxc) onSuccess(mxc);
|
if (mxc) {
|
||||||
else onError(new MatrixError(data));
|
onSuccess(mxc);
|
||||||
} catch (e: any) {
|
return;
|
||||||
const error = typeof e?.message === 'string' ? e.message : undefined;
|
|
||||||
const errcode = typeof e?.name === 'string' ? e.message : undefined;
|
|
||||||
onError(new MatrixError({ error, errcode }));
|
|
||||||
}
|
}
|
||||||
|
// 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();
|
export const matrixEventByRecency = (m1: MatrixEvent, m2: MatrixEvent) => m2.getTs() - m1.getTs();
|
||||||
@@ -230,11 +297,11 @@ export const addRoomIdToMDirect = async (
|
|||||||
roomId: string,
|
roomId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
|
const mDirectsEvent = mx.getAccountData(EventType.Direct);
|
||||||
let userIdToRoomIds: Record<string, string[]> = {};
|
let userIdToRoomIds: MDirectContent = {};
|
||||||
|
|
||||||
if (typeof mDirectsEvent !== 'undefined')
|
if (typeof mDirectsEvent !== 'undefined')
|
||||||
userIdToRoomIds = structuredClone(mDirectsEvent.getContent());
|
userIdToRoomIds = structuredClone(mDirectsEvent.getContent<MDirectContent>());
|
||||||
|
|
||||||
// remove it from the lists of any others users
|
// remove it from the lists of any others users
|
||||||
// (it can only be a DM room for one person)
|
// (it can only be a DM room for one person)
|
||||||
@@ -255,15 +322,15 @@ export const addRoomIdToMDirect = async (
|
|||||||
}
|
}
|
||||||
userIdToRoomIds[userId] = roomIds;
|
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> => {
|
export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise<void> => {
|
||||||
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
|
const mDirectsEvent = mx.getAccountData(EventType.Direct);
|
||||||
let userIdToRoomIds: Record<string, string[]> = {};
|
let userIdToRoomIds: MDirectContent = {};
|
||||||
|
|
||||||
if (typeof mDirectsEvent !== 'undefined')
|
if (typeof mDirectsEvent !== 'undefined')
|
||||||
userIdToRoomIds = structuredClone(mDirectsEvent.getContent());
|
userIdToRoomIds = structuredClone(mDirectsEvent.getContent<MDirectContent>());
|
||||||
|
|
||||||
Object.keys(userIdToRoomIds).forEach((targetUserId) => {
|
Object.keys(userIdToRoomIds).forEach((targetUserId) => {
|
||||||
const roomIds = userIdToRoomIds[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 = (
|
export const mxcUrlToHttp = (
|
||||||
|
|||||||
Reference in New Issue
Block a user