From 004014be3a584e9bb05e8cd1f06f31a00b8ed902 Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 23:10:00 +0200 Subject: [PATCH] feat(inventory): auto-recover from a transient stream failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- apps/app_seeds/lib/state/inventory_cubit.dart | 61 +++++++++++++---- apps/app_seeds/pubspec.yaml | 2 + .../test/state/inventory_cubit_test.dart | 65 +++++++++++++------ 3 files changed, 94 insertions(+), 34 deletions(-) diff --git a/apps/app_seeds/lib/state/inventory_cubit.dart b/apps/app_seeds/lib/state/inventory_cubit.dart index ddc8c39..972924e 100644 --- a/apps/app_seeds/lib/state/inventory_cubit.dart +++ b/apps/app_seeds/lib/state/inventory_cubit.dart @@ -167,6 +167,18 @@ class InventoryCubit extends Cubit { >? _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 { /// 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 { @override Future close() async { + _retryTimer?.cancel(); await _sub?.cancel(); return super.close(); } diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml index 75ee26e..c139eae 100644 --- a/apps/app_seeds/pubspec.yaml +++ b/apps/app_seeds/pubspec.yaml @@ -86,6 +86,8 @@ dev_dependencies: sdk: flutter flutter_lints: ^6.0.0 + # Virtual clock to fast-forward the inventory auto-retry backoff in tests. + fake_async: ^1.3.1 build_runner: ^2.4.13 drift_dev: ^2.28.0 slang_build_runner: ^4.7.0 diff --git a/apps/app_seeds/test/state/inventory_cubit_test.dart b/apps/app_seeds/test/state/inventory_cubit_test.dart index 6a4547d..fbabece 100644 --- a/apps/app_seeds/test/state/inventory_cubit_test.dart +++ b/apps/app_seeds/test/state/inventory_cubit_test.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'package:commons_core/commons_core.dart'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/data/variety_repository.dart'; import 'package:tane/db/database.dart'; @@ -9,20 +10,23 @@ import 'package:tane/state/inventory_cubit.dart'; import '../support/test_support.dart'; -/// A repository whose inventory stream errors until [healthy] is flipped — -/// mimics the transient DB-not-ready failure that used to leave the spinner -/// stuck forever. +/// A repository whose inventory stream errors for the first +/// [failuresBeforeHealthy] subscriptions, then behaves normally — mimics the +/// transient DB-not-ready failure that used to leave the spinner stuck forever. +/// The default never recovers, for the "give up" path. class _FailingRepository extends VarietyRepository { // ignore: use_super_parameters - _FailingRepository(AppDatabase db) + _FailingRepository(AppDatabase db, {this.failuresBeforeHealthy = 1 << 30}) : super(db, idGen: IdGen(), nodeId: 'test-node'); - bool healthy = false; + final int failuresBeforeHealthy; + int subscriptions = 0; @override Stream<({List items, List drafts})> - watchInventoryView() => - healthy ? super.watchInventoryView() : Stream.error(StateError('boom')); + watchInventoryView() => subscriptions++ < failuresBeforeHealthy + ? Stream.error(StateError('boom')) + : super.watchInventoryView(); } /// Waits until the cubit's state satisfies [predicate], whether it already @@ -57,30 +61,49 @@ void main() { }); group('stream failure', () { - test('a failing stream drops loading and surfaces an error, not a ' - 'forever-spinner', () async { - final failing = _FailingRepository(db); + test('a transient failure recovers on its own — no error, no manual retry', + () async { + // Fails the first two subscribes, then the DB is ready. + final failing = _FailingRepository(db, failuresBeforeHealthy: 2); + await failing.addQuickVariety(label: 'Maize'); final failCubit = InventoryCubit(failing); addTearDown(failCubit.close); - final state = await waitFor(failCubit, (s) => s.error != null); - expect(state.loading, isFalse); - expect(state.error, contains('boom')); + // Auto-retry with backoff brings it back without ever showing an error. + final state = await waitFor(failCubit, (s) => !s.loading && s.error == null); + expect(state.items.single.label, 'Maize'); + expect(failing.subscriptions, greaterThan(1)); }); - test('retry re-opens the stream and recovers once the DB is healthy', + test('stays in loading (spinner), never flips to error, while auto-retrying', () async { - final failing = _FailingRepository(db); + final failing = _FailingRepository(db, failuresBeforeHealthy: 2); + await failing.addQuickVariety(label: 'Maize'); final failCubit = InventoryCubit(failing); addTearDown(failCubit.close); - await waitFor(failCubit, (s) => s.error != null); - await failing.addQuickVariety(label: 'Maize'); - failing.healthy = true; - failCubit.retry(); + final errors = []; + final sub = failCubit.stream.listen((s) => errors.add(s.error)); + addTearDown(sub.cancel); - final state = await waitFor(failCubit, (s) => s.error == null && !s.loading); - expect(state.items.single.label, 'Maize'); + await waitFor(failCubit, (s) => !s.loading && s.error == null); + expect(errors.every((e) => e == null), isTrue, + reason: 'no error state should be emitted during auto-recovery'); + }); + + test('gives up after the retry budget and surfaces a manual-retry error', + () { + fakeAsync((async) { + final failing = _FailingRepository(db); // never recovers + final failCubit = InventoryCubit(failing); + addTearDown(failCubit.close); + + // Fast-forward past all backoff windows (~12s of retries). + async.elapse(const Duration(seconds: 30)); + + expect(failCubit.state.loading, isFalse); + expect(failCubit.state.error, contains('boom')); + }); }); });