fix(status): status save no longer fails on Synapse's 1-per-10s presence limit
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -38,6 +38,7 @@ import { SettingTile } from '../../../components/setting-tile';
|
|||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { getAccountData, setAccountData } from '../../../utils/accountData';
|
import { getAccountData, setAccountData } from '../../../utils/accountData';
|
||||||
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
|
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
|
||||||
|
import { describePresenceError, setPresenceWithRetry } from '../../../utils/presenceWrite';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../../state/settings';
|
import { settingsAtom } from '../../../state/settings';
|
||||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||||
@@ -424,7 +425,10 @@ function ProfileStatus() {
|
|||||||
const [saveState, saveStatus] = useAsyncCallback(
|
const [saveState, saveStatus] = useAsyncCallback(
|
||||||
useCallback(
|
useCallback(
|
||||||
(msg: string) =>
|
(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
|
// Derive presence from the user's chosen setting so writing a status
|
||||||
// never overrides Invisible/DND/Idle (e.g. outing an Invisible user).
|
// never overrides Invisible/DND/Idle (e.g. outing an Invisible user).
|
||||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||||
@@ -690,7 +694,7 @@ function ProfileStatus() {
|
|||||||
</Box>
|
</Box>
|
||||||
{saveState.status === AsyncStatus.Error && (
|
{saveState.status === AsyncStatus.Error && (
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
Failed to save status — server may be rate limiting. Try again.
|
{describePresenceError(saveState.error)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Box alignItems="Center" gap="200">
|
<Box alignItems="Center" gap="200">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { PresenceDeduper, PresenceWrite } from '../utils/presenceWrite';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
@@ -33,9 +34,13 @@ export function usePresenceUpdater() {
|
|||||||
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const isIdleRef = useRef(false);
|
const isIdleRef = useRef(false);
|
||||||
const lastActivityRef = useRef(0);
|
const lastActivityRef = useRef(0);
|
||||||
|
const deduperRef = useRef(new PresenceDeduper());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const userId = mx.getUserId();
|
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
|
// Read status from localStorage at call time so manual updates from the
|
||||||
// Profile settings are never overwritten by a stale closure value.
|
// 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);
|
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 setOnline = () => {
|
||||||
const status = readStatus();
|
const status = readStatus();
|
||||||
return mx
|
return send({ presence: 'online', ...(status ? { status_msg: status } : {}) }, 'online');
|
||||||
.setPresence({
|
|
||||||
presence: 'online',
|
|
||||||
...(status ? { status_msg: status } : {}),
|
|
||||||
})
|
|
||||||
.catch((err) => warnPresenceFailure('online', err));
|
|
||||||
};
|
};
|
||||||
const setUnavailable = (statusMsg?: string) => {
|
const setUnavailable = (statusMsg?: string) => {
|
||||||
const status = readStatus();
|
const status = readStatus();
|
||||||
return mx
|
return send(
|
||||||
.setPresence({
|
{
|
||||||
presence: 'unavailable',
|
presence: 'unavailable',
|
||||||
...(statusMsg ? { status_msg: statusMsg } : status ? { status_msg: status } : {}),
|
...(statusMsg ? { status_msg: statusMsg } : status ? { status_msg: status } : {}),
|
||||||
})
|
},
|
||||||
.catch((err) => warnPresenceFailure('unavailable', err));
|
'unavailable',
|
||||||
|
);
|
||||||
};
|
};
|
||||||
const setOffline = () =>
|
const setOffline = () => send({ presence: 'offline', status_msg: '' }, 'offline');
|
||||||
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') {
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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<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.";
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user