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:
2026-09-19 17:41:09 -04:00
co-authored by Claude Opus 5
parent bbe91a24d8
commit f5e7fb4746
4 changed files with 183 additions and 16 deletions
+81
View File
@@ -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/);
});
+73
View File
@@ -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.";
};