From f5e7fb47464e7fb62420b22cbbd389f54302a983 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 19 Sep 2026 17:41:09 -0400 Subject: [PATCH] fix(status): status save no longer fails on Synapse's 1-per-10s presence limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: 'Failed to save status — server may be rate limiting. Try again.' on the first status change in a month. Root cause: Synapse rate-limits PUT /presence/{user}/status to ONE request per 10 s per user by default (rc_presence.per_user: per_second 0.1, burst_count 1), shared across all of the user's devices, and our presence heartbeat (online/away on visibility and activity changes, from every open tab/device) spends that budget — so a manual save that lands within 10 s of a heartbeat gets a 429, which the form showed as a dead end. Two fixes: (1) the status save waits out Retry-After (bounded to ~25 s) via setPresenceWithRetry instead of failing, and the error text now says what actually happened (rate limit / server text / offline); (2) the heartbeat dedupes — it only sends when presence or status actually changes (/sync already keeps us online), so it stops burning the budget in the first place. Unit-tested; reproduced headless with a routed 10 s limiter: heartbeat ok → save 429 → retried 8 s later → saved. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/features/settings/account/Profile.tsx | 8 +- src/app/hooks/usePresenceUpdater.ts | 37 +++++---- src/app/utils/presenceWrite.test.ts | 81 +++++++++++++++++++ src/app/utils/presenceWrite.ts | 73 +++++++++++++++++ 4 files changed, 183 insertions(+), 16 deletions(-) create mode 100644 src/app/utils/presenceWrite.test.ts create mode 100644 src/app/utils/presenceWrite.ts diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 223bd44e3..9fc2e463d 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -38,6 +38,7 @@ import { SettingTile } from '../../../components/setting-tile'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getAccountData, setAccountData } from '../../../utils/accountData'; import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater'; +import { describePresenceError, setPresenceWithRetry } from '../../../utils/presenceWrite'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile'; @@ -424,7 +425,10 @@ function ProfileStatus() { const [saveState, saveStatus] = useAsyncCallback( useCallback( (msg: string) => - mx.setPresence({ + // Synapse allows ONE presence write per 10 s per user (shared by all + // devices) and our heartbeat spends that budget too — wait out a 429 + // instead of failing the user's save. + setPresenceWithRetry(mx, { // Derive presence from the user's chosen setting so writing a status // never overrides Invisible/DND/Idle (e.g. outing an Invisible user). presence: presenceStateFromSetting(presenceStatus, hidePresence), @@ -690,7 +694,7 @@ function ProfileStatus() { {saveState.status === AsyncStatus.Error && ( - Failed to save status — server may be rate limiting. Try again. + {describePresenceError(saveState.error)} )} diff --git a/src/app/hooks/usePresenceUpdater.ts b/src/app/hooks/usePresenceUpdater.ts index b9513fcbe..becb5865d 100644 --- a/src/app/hooks/usePresenceUpdater.ts +++ b/src/app/hooks/usePresenceUpdater.ts @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react'; +import { PresenceDeduper, PresenceWrite } from '../utils/presenceWrite'; import { useMatrixClient } from './useMatrixClient'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; @@ -33,9 +34,13 @@ export function usePresenceUpdater() { const idleTimerRef = useRef | undefined>(undefined); const isIdleRef = useRef(false); const lastActivityRef = useRef(0); + const deduperRef = useRef(new PresenceDeduper()); useEffect(() => { const userId = mx.getUserId(); + const deduper = deduperRef.current; + // A manual status save changes what the next heartbeat should carry. + deduper.reset(); // Read status from localStorage at call time so manual updates from the // Profile settings are never overwritten by a stale closure value. @@ -49,28 +54,32 @@ export function usePresenceUpdater() { console.warn(`Failed to set presence to "${presence}":`, reason); }; + // Synapse allows ONE presence write per 10 s per user (rc_presence, shared + // by all devices) and /sync already keeps us online — only send when the + // state or status actually changes, so a manual status save isn't 429'd + // by a heartbeat that changed nothing. + const send = (write: PresenceWrite, label: string) => { + if (!deduper.shouldSend(write)) return Promise.resolve(); + return mx + .setPresence(write) + .then(() => deduper.sent(write)) + .catch((err) => warnPresenceFailure(label, err)); + }; const setOnline = () => { const status = readStatus(); - return mx - .setPresence({ - presence: 'online', - ...(status ? { status_msg: status } : {}), - }) - .catch((err) => warnPresenceFailure('online', err)); + return send({ presence: 'online', ...(status ? { status_msg: status } : {}) }, 'online'); }; const setUnavailable = (statusMsg?: string) => { const status = readStatus(); - return mx - .setPresence({ + return send( + { presence: 'unavailable', ...(statusMsg ? { status_msg: statusMsg } : status ? { status_msg: status } : {}), - }) - .catch((err) => warnPresenceFailure('unavailable', err)); + }, + 'unavailable', + ); }; - const setOffline = () => - mx - .setPresence({ presence: 'offline', status_msg: '' }) - .catch((err) => warnPresenceFailure('offline', err)); + const setOffline = () => send({ presence: 'offline', status_msg: '' }, 'offline'); // Manual presence overrides — no activity tracking needed. if (hidePresence || presenceStatus === 'invisible') { diff --git a/src/app/utils/presenceWrite.test.ts b/src/app/utils/presenceWrite.test.ts new file mode 100644 index 000000000..2a7333ffc --- /dev/null +++ b/src/app/utils/presenceWrite.test.ts @@ -0,0 +1,81 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { MatrixError } from 'matrix-js-sdk'; +import { PresenceDeduper, describePresenceError, setPresenceWithRetry } from './presenceWrite'; + +const limited = (retryMs = 3000) => + new MatrixError( + { errcode: 'M_LIMIT_EXCEEDED', error: 'Too Many Requests', retry_after_ms: retryMs }, + 429, + ); + +test('429 is retried after Retry-After, then succeeds', async () => { + let n = 0; + const waits: number[] = []; + const mx = { + setPresence: async () => { + n += 1; + if (n < 3) throw limited(3000); + return {}; + }, + }; + await setPresenceWithRetry( + mx, + { presence: 'online', status_msg: 'hi' }, + { + sleepFn: async (ms) => { + waits.push(ms); + }, + }, + ); + assert.equal(n, 3); + assert.deepEqual(waits, [3000, 3000]); +}); + +test('gives up once the total wait would exceed the cap; other errors are not retried', async () => { + const mx = { + setPresence: async () => { + throw limited(20_000); + }, + }; + await assert.rejects( + () => + setPresenceWithRetry( + mx, + { presence: 'online' }, + { maxWaitMs: 25_000, sleepFn: async () => undefined }, + ), + /Too Many/, + ); + let calls = 0; + const forbidden = { + setPresence: async () => { + calls += 1; + throw new MatrixError({ errcode: 'M_FORBIDDEN', error: 'nope' }, 403); + }, + }; + await assert.rejects(() => setPresenceWithRetry(forbidden, { presence: 'online' })); + assert.equal(calls, 1); +}); + +test('deduper skips identical writes and notices changes', () => { + const d = new PresenceDeduper(); + assert.equal(d.shouldSend({ presence: 'online', status_msg: 'a' }), true); + d.sent({ presence: 'online', status_msg: 'a' }); + assert.equal(d.shouldSend({ presence: 'online', status_msg: 'a' }), false); + assert.equal(d.shouldSend({ presence: 'unavailable', status_msg: 'a' }), true); + assert.equal(d.shouldSend({ presence: 'online', status_msg: 'b' }), true); + d.reset(); + assert.equal(d.shouldSend({ presence: 'online', status_msg: 'a' }), true); +}); + +test('error wording', () => { + assert.match(describePresenceError(limited()), /once every 10 seconds/); + assert.equal( + describePresenceError( + new MatrixError({ errcode: 'M_FORBIDDEN', error: 'Presence is disabled' }, 403), + ), + 'Failed to save status: Presence is disabled', + ); + assert.match(describePresenceError(new TypeError('Failed to fetch')), /couldn't reach/); +}); diff --git a/src/app/utils/presenceWrite.ts b/src/app/utils/presenceWrite.ts new file mode 100644 index 000000000..209745380 --- /dev/null +++ b/src/app/utils/presenceWrite.ts @@ -0,0 +1,73 @@ +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((r) => { + setTimeout(r, ms); + }); + +export async function setPresenceWithRetry( + mx: Pick, + write: PresenceWrite, + opts: { maxWaitMs?: number; sleepFn?: (ms: number) => Promise } = {}, +): Promise { + 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."; +};