feat(inventory): auto-recover from a transient stream failure

Instead of showing a retry button on the first failure, silently re-open
the inventory stream with exponential backoff (250ms→4s, 6 attempts,
staying in the loading spinner). The startup DB-not-ready race clears in
well under a second, so the user just sees the spinner briefly and the
list appears — no tap needed. Only after the retry budget is exhausted do
we surface the manual-retry error.

Tests cover auto-recovery, no error flicker during retries, and the
give-up path (via fake_async to fast-forward the backoff).
This commit is contained in:
vjrj 2026-07-10 23:10:00 +02:00
parent f45c452615
commit 004014be3a
3 changed files with 94 additions and 34 deletions

View file

@ -167,6 +167,18 @@ class InventoryCubit extends Cubit<InventoryState> {
>?
_sub;
/// Pending auto-retry, cancelled on a fresh (re)subscribe or on close.
Timer? _retryTimer;
/// Consecutive stream failures since the last good emission. Drives the
/// backoff and, once [_maxAutoRetries] is hit, the switch to a manual retry.
int _failures = 0;
/// How many times we silently re-open the stream before giving up and asking
/// the user. The startup DB-not-ready race clears in well under a second, so
/// a handful of backed-off attempts recovers it without the user noticing.
static const _maxAutoRetries = 6;
/// (Re)opens the combined inventory subscription (list + draft tray). One
/// subscription only: two separate StreamGroups here re-emit in a loop that
/// hangs widget tests see watchInventoryView.
@ -174,26 +186,48 @@ class InventoryCubit extends Cubit<InventoryState> {
/// The onError handler is load-bearing: without it a transient stream failure
/// (e.g. the encrypted DB not yet ready at startup) would go unhandled and
/// leave [InventoryState.loading] true forever the "stuck spinner" that a
/// restart clears. On error we drop out of loading and surface [error] so the
/// UI can offer [retry].
/// restart clears. On error we auto-retry with backoff (staying in [loading]
/// so the user just sees the spinner briefly), and only surface [error] for a
/// manual [retry] once the transient window has clearly passed.
void _subscribe() {
_retryTimer?.cancel();
_sub?.cancel();
_sub = _repo.watchInventoryView().listen(
(view) => emit(
state.copyWith(
items: view.items,
drafts: view.drafts,
loading: false,
error: () => null,
),
),
onError: (Object e) =>
emit(state.copyWith(loading: false, error: () => '$e')),
(view) {
_failures = 0;
emit(
state.copyWith(
items: view.items,
drafts: view.drafts,
loading: false,
error: () => null,
),
);
},
onError: _onStreamError,
);
}
/// Re-opens the inventory stream after a failure, back to the loading state.
void _onStreamError(Object e) {
if (isClosed) return;
_failures++;
if (_failures <= _maxAutoRetries) {
// Exponential backoff capped at ~4s: 250ms, 500ms, 1s, 2s, 4s, 4s.
final delayMs = (250 * (1 << (_failures - 1))).clamp(250, 4000);
// Stay in loading an auto-recovering spinner, not an error screen.
_retryTimer = Timer(Duration(milliseconds: delayMs), () {
if (!isClosed) _subscribe();
});
} else {
// Transient window has passed; hand it to the user.
emit(state.copyWith(loading: false, error: () => '$e'));
}
}
/// Re-opens the inventory stream on demand (from the manual retry button),
/// resetting the auto-retry budget and returning to the loading state.
void retry() {
_failures = 0;
emit(state.copyWith(loading: true, error: () => null));
_subscribe();
}
@ -269,6 +303,7 @@ class InventoryCubit extends Cubit<InventoryState> {
@override
Future<void> close() async {
_retryTimer?.cancel();
await _sub?.cancel();
return super.close();
}