mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
### What changed? Mobile now recovers live subscriptions after retryable or rate-limited relay `CLOSED` responses. It ports the existing desktop model: classify terminal versus retryable closures, honor retry hints through a session-owned rate-limit gate, retry with bounded backoff, and replay visible-channel subscriptions first in bounded batches. Channel refreshes also retain unchanged live subscriptions instead of clearing and recreating them. This is desktop parity, not a new relay policy. ### Why? On reconnect or resume, mobile replayed its retained live subscriptions while `channelsProvider` independently cleared and recreated roughly the same set, alongside unread catch-up and open-channel requests. The relay allows 50 REQs per 5 seconds, so users in many channels could predictably exceed the budget. In live reproduction, 55 subscriptions produced 9 rate-limit closures, 60 produced 18, and 80 produced 36. Mobile then treated every live `CLOSED` as terminal, removed the affected subscription, and never restored it. Channel updates could remain dead until a later session reconstruction. This is the primary causal chain behind [BOT-1449](https://linear.app/squareup/issue/BOT-1449/buzz-mobile-posted-messages-dont-appear-until-leavingre-entering-the). Desktop already handles this as normal transient pressure by classifying closures, gating and backing off retries, pacing reconnect replay, and retaining unchanged subscriptions. This change brings mobile to the same recovery model while removing the avoidable request burst. ### How is it tested? Full mobile suite: 721 passed, 1 skipped. Analyzer and formatting checks pass. Required CI checks pass. Added and updated tests cover `CLOSED` classification, retry hints, rate-limit gating, bounded retry and reset behavior, terminal failures, timer cleanup, history gating, visible-first batched replay, and retention of unchanged subscriptions. --------- Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: npub1tu6ed4gf70jg7pvk8uhttlprexznhzpg74am2d3seqd3ececzgusy8hzac <5f3596d509f3e48f05963f2eb5fc23c9853b8828f57bb53630c81b1ce3381239@buzz.block.builderlab.xyz> Co-authored-by: npub1w85l93z2dyetvaev42kvmgv3r5qsgc7rutrvgpqshqefj4sydqqskwstfm <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz>
46 lines
1.8 KiB
Dart
46 lines
1.8 KiB
Dart
import 'package:buzz/shared/relay/relay.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
void main() {
|
|
group('classifyRelayClosed', () {
|
|
const cases = {
|
|
'rate-limited: quota exceeded; retry in 4s': RelayClosedClass.rateLimited,
|
|
'rate-limited: too many concurrent requests':
|
|
RelayClosedClass.rateLimited,
|
|
'restricted: access revoked': RelayClosedClass.terminal,
|
|
'auth-required: verification failed': RelayClosedClass.terminal,
|
|
'blocked: community policy': RelayClosedClass.terminal,
|
|
'invalid: malformed filter': RelayClosedClass.terminal,
|
|
'pow: insufficient work': RelayClosedClass.terminal,
|
|
'duplicate: subscription already exists': RelayClosedClass.terminal,
|
|
'unsupported: filter extension': RelayClosedClass.terminal,
|
|
'error: mixed search and channel filter': RelayClosedClass.terminal,
|
|
'error: too many subscriptions': RelayClosedClass.terminal,
|
|
'error: relay temporarily unavailable': RelayClosedClass.retryable,
|
|
'subscription closed by relay': RelayClosedClass.retryable,
|
|
};
|
|
|
|
for (final entry in cases.entries) {
|
|
test(entry.key, () {
|
|
expect(classifyRelayClosed(entry.key), entry.value);
|
|
});
|
|
}
|
|
});
|
|
|
|
test('parseRateLimitRetrySeconds handles canonical and absent hints', () {
|
|
expect(
|
|
parseRateLimitRetrySeconds('rate-limited: quota exceeded; retry in 17s'),
|
|
17,
|
|
);
|
|
expect(parseRateLimitRetrySeconds('RETRY IN 2S'), 2);
|
|
expect(parseRateLimitRetrySeconds('retry in 0s'), 0);
|
|
expect(parseRateLimitRetrySeconds('retry in 999999s'), 999999);
|
|
final oversizedHint = List.filled(1000, '9').join();
|
|
expect(parseRateLimitRetrySeconds('retry in ${oversizedHint}s'), isNull);
|
|
expect(
|
|
parseRateLimitRetrySeconds('rate-limited: too many concurrent requests'),
|
|
isNull,
|
|
);
|
|
});
|
|
}
|