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>
81 lines
2.4 KiB
Dart
81 lines
2.4 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
|
|
/// Creates a timer used by [RelayRateLimitGate].
|
|
typedef RelayTimerFactory =
|
|
Timer Function(Duration duration, void Function() callback);
|
|
|
|
/// Session-owned gate that pauses relay requests after back-pressure.
|
|
class RelayRateLimitGate {
|
|
/// Default gate duration when the relay omits a positive retry hint.
|
|
static const defaultRetrySeconds = 10;
|
|
|
|
/// Longest retry hint accepted from a relay response.
|
|
static const maxRetrySeconds = 300;
|
|
|
|
RelayRateLimitGate({
|
|
DateTime Function()? now,
|
|
RelayTimerFactory timerFactory = Timer.new,
|
|
}) : _now = now ?? DateTime.now,
|
|
_timerFactory = timerFactory;
|
|
|
|
final DateTime Function() _now;
|
|
final RelayTimerFactory _timerFactory;
|
|
DateTime? _expiresAt;
|
|
Timer? _timer;
|
|
Completer<void>? _completer;
|
|
|
|
/// Whether a rate-limit window is currently active.
|
|
bool get isActive {
|
|
final expiresAt = _expiresAt;
|
|
return expiresAt != null && _now().isBefore(expiresAt);
|
|
}
|
|
|
|
/// Activates or extends the gate without shrinking an existing window.
|
|
void activate(int? retryInSeconds) {
|
|
final seconds = retryInSeconds != null && retryInSeconds > 0
|
|
? min(retryInSeconds, maxRetrySeconds)
|
|
: defaultRetrySeconds;
|
|
final duration = Duration(seconds: seconds);
|
|
final newExpiry = _now().add(duration);
|
|
final currentExpiry = _expiresAt;
|
|
if (currentExpiry != null && !newExpiry.isAfter(currentExpiry)) return;
|
|
|
|
_expiresAt = newExpiry;
|
|
_timer?.cancel();
|
|
_completer ??= Completer<void>();
|
|
_timer = _timerFactory(duration, _expire);
|
|
}
|
|
|
|
/// Resolves when the active rate-limit window expires.
|
|
Future<void> wait() {
|
|
if (!isActive) return Future.value();
|
|
return _completer!.future;
|
|
}
|
|
|
|
/// Milliseconds remaining in the active window, or zero when inactive.
|
|
int remainingMs() {
|
|
final expiresAt = _expiresAt;
|
|
if (expiresAt == null) return 0;
|
|
return max(0, expiresAt.difference(_now()).inMilliseconds);
|
|
}
|
|
|
|
/// Clears the gate and releases all current waiters.
|
|
void reset() {
|
|
_timer?.cancel();
|
|
_timer = null;
|
|
_expiresAt = null;
|
|
final completer = _completer;
|
|
_completer = null;
|
|
if (completer != null && !completer.isCompleted) completer.complete();
|
|
}
|
|
|
|
void _expire() {
|
|
_timer = null;
|
|
_expiresAt = null;
|
|
final completer = _completer;
|
|
_completer = null;
|
|
if (completer != null && !completer.isCompleted) completer.complete();
|
|
}
|
|
}
|