74 lines
2.5 KiB
TypeScript
74 lines
2.5 KiB
TypeScript
import { MatrixClient, MatrixError } from 'matrix-js-sdk';
|
|||
|
|
|
||
|
|
/**
|
||
|
|
* Synapse rate-limits `PUT /presence/{user}/status` to ONE request per 10 s per
|
||
|
|
* user by default (`rc_presence.per_user`: 0.1/s, burst 1) — and the limit is
|
||
|
|
* shared across all of a user's devices. Our presence heartbeat (online/away on
|
||
|
|
* visibility and activity changes) can therefore make a manual status save 429.
|
||
|
|
*
|
||
|
|
* `setPresenceWithRetry` waits out `Retry-After` (bounded) instead of failing,
|
||
|
|
* and `PresenceDeduper` lets the heartbeat skip writes that would not change
|
||
|
|
* anything, so it stops spending the budget in the first place.
|
||
|
|
*/
|
||
|
|
export type PresenceWrite = { presence: 'online' | 'offline' | 'unavailable'; status_msg?: string };
|
||
|
|
|
||
|
|
const sleep = (ms: number) =>
|
||
|
|
new Promise<void>((r) => {
|
||
|
|
setTimeout(r, ms);
|
||
|
|
});
|
||
|
|
|
||
|
|
export async function setPresenceWithRetry(
|
||
|
|
mx: Pick<MatrixClient, 'setPresence'>,
|
||
|
|
write: PresenceWrite,
|
||
|
|
opts: { maxWaitMs?: number; sleepFn?: (ms: number) => Promise<void> } = {},
|
||
|
|
): Promise<void> {
|
||
|
|
const maxWaitMs = opts.maxWaitMs ?? 25_000;
|
||
|
|
const sleepFn = opts.sleepFn ?? sleep;
|
||
|
|
let waited = 0;
|
||
|
|
for (;;) {
|
||
|
|
try {
|
||
|
|
// eslint-disable-next-line no-await-in-loop
|
||
|
|
await mx.setPresence(write);
|
||
|
|
return;
|
||
|
|
} catch (e) {
|
||
|
|
if (!(e instanceof MatrixError) || e.httpStatus !== 429) throw e;
|
||
|
|
const wait = Math.min(e.getRetryAfterMs() ?? 10_500, 15_000);
|
||
|
|
if (waited + wait > maxWaitMs) throw e;
|
||
|
|
waited += wait;
|
||
|
|
// eslint-disable-next-line no-await-in-loop
|
||
|
|
await sleepFn(wait);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const presenceKey = (write: PresenceWrite): string => `${write.presence}|${write.status_msg ?? ''}`;
|
||
|
|
|
||
|
|
/** Remembers the last write that succeeded so identical heartbeats are skipped. */
|
||
|
|
export class PresenceDeduper {
|
||
|
|
private last: string | null = null;
|
||
|
|
|
||
|
|
shouldSend(write: PresenceWrite): boolean {
|
||
|
|
return presenceKey(write) !== this.last;
|
||
|
|
}
|
||
|
|
|
||
|
|
sent(write: PresenceWrite): void {
|
||
|
|
this.last = presenceKey(write);
|
||
|
|
}
|
||
|
|
|
||
|
|
reset(): void {
|
||
|
|
this.last = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** A user-facing sentence for a failed status save. */
|
||
|
|
export const describePresenceError = (e: unknown): string => {
|
||
|
|
if (e instanceof MatrixError) {
|
||
|
|
if (e.httpStatus === 429) {
|
||
|
|
return 'The server limits how often presence can change (once every 10 seconds, shared by all your devices). Please wait a moment and try again.';
|
||
|
|
}
|
||
|
|
const serverText = (e.data as { error?: string } | undefined)?.error;
|
||
|
|
if (serverText) return `Failed to save status: ${serverText}`;
|
||
|
|
}
|
||
|
|
return "Failed to save status — couldn't reach the server. Try again.";
|
||
|
|
};
|