Skip to content

Commit f3bd2ac

Browse files
committed
store: Report polling errors with details.
We extend the checkRetry helper to support simulating multiple consecutive errors. Signed-off-by: Zixuan James Li <[email protected]>
1 parent ab85c92 commit f3bd2ac

File tree

3 files changed

+82
-11
lines changed

3 files changed

+82
-11
lines changed

assets/l10n/app_en.arb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,18 @@
160160
"message": {"type": "String", "example": "Invalid format"}
161161
}
162162
},
163+
"errorLoadingServerDataShort": "Error connecting to Zulip. Retrying…",
164+
"@errorLoadingServerDataShort": {
165+
"description": "Short error message for a generic unknown error connecting to the server."
166+
},
167+
"errorLoadingServerDataDetails": "Error connecting to Zulip at {serverUrl}. Will retry:\n\n{error}",
168+
"@errorLoadingServerDataDetails": {
169+
"description": "Dialog error message for a generic unknown error connecting to server with details.",
170+
"placeholders": {
171+
"serverUrl": {"type": "String", "example": "http://example.com/"},
172+
"error": {"type": "String", "example": "Invalid format"}
173+
}
174+
},
163175
"errorSharingFailed": "Sharing failed",
164176
"@errorSharingFailed": {
165177
"description": "Error message when sharing a message failed."

lib/model/store.dart

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import '../log.dart';
2020
import '../notifications/receive.dart';
2121
import 'autocomplete.dart';
2222
import 'database.dart';
23+
import 'localizations.dart';
2324
import 'message.dart';
2425
import 'message_list.dart';
2526
import 'recent_dm_conversations.dart';
@@ -770,6 +771,8 @@ class UpdateMachine {
770771

771772
void poll() async {
772773
BackoffMachine? backoffMachine;
774+
int accumulatedTransientFailureCount = 0;
775+
const transientFailureCountNotifyThreshold = 5;
773776

774777
while (true) {
775778
if (_debugLoopSignal != null) {
@@ -786,6 +789,8 @@ class UpdateMachine {
786789
queueId: queueId, lastEventId: lastEventId);
787790
} catch (e) {
788791
store.isLoading = true;
792+
final localizations = GlobalLocalizations.zulipLocalizations;
793+
final serverUrl = store.connection.realmUrl.origin;
789794
switch (e) {
790795
case ZulipApiException(code: 'BAD_EVENT_QUEUE_ID'):
791796
assert(debugLog('Lost event queue for $store. Replacing…'));
@@ -797,15 +802,25 @@ class UpdateMachine {
797802
case Server5xxException() || NetworkException():
798803
assert(debugLog('Transient error polling event queue for $store: $e\n'
799804
'Backing off, then will retry…'));
800-
// TODO tell user if transient polling errors persist
805+
accumulatedTransientFailureCount++;
806+
if (accumulatedTransientFailureCount > transientFailureCountNotifyThreshold) {
807+
reportErrorToUserBriefly(
808+
localizations.errorLoadingServerDataShort,
809+
details: localizations.errorLoadingServerDataDetails(
810+
serverUrl, e.toString()));
811+
}
801812
await (backoffMachine ??= BackoffMachine()).wait();
802813
assert(debugLog('… Backoff wait complete, retrying poll.'));
803814
continue;
804815

805816
default:
806817
assert(debugLog('Error polling event queue for $store: $e\n'
807818
'Backing off and retrying even though may be hopeless…'));
808-
// TODO tell user on non-transient error in polling
819+
// TODO(#186): Handle unrecoverable failures
820+
reportErrorToUserBriefly(
821+
localizations.errorLoadingServerDataShort,
822+
details: localizations.errorLoadingServerDataDetails(
823+
serverUrl, e.toString()));
809824
await (backoffMachine ??= BackoffMachine()).wait();
810825
assert(debugLog('… Backoff wait complete, retrying poll.'));
811826
continue;
@@ -829,6 +844,9 @@ class UpdateMachine {
829844
// and failures, the successes themselves should space out the requests.
830845
backoffMachine = null;
831846
store.isLoading = false;
847+
// Dismiss existing errors, if any.
848+
reportErrorToUserBriefly(null);
849+
accumulatedTransientFailureCount = 0;
832850

833851
final events = result.events;
834852
for (final event in events) {

test/model/store_test.dart

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import 'package:zulip/api/model/events.dart';
88
import 'package:zulip/api/model/model.dart';
99
import 'package:zulip/api/route/events.dart';
1010
import 'package:zulip/api/route/messages.dart';
11+
import 'package:zulip/log.dart';
1112
import 'package:zulip/model/store.dart';
1213
import 'package:zulip/notifications/receive.dart';
1314

@@ -391,6 +392,22 @@ void main() {
391392
check(store.userSettings!.twentyFourHourTime).isTrue();
392393
}));
393394

395+
String? lastReportedError;
396+
String? takeLastReportedError() {
397+
final result = lastReportedError;
398+
lastReportedError = null;
399+
return result;
400+
}
401+
402+
void logAndReportErrorToUserBriefly(String? message, {String? details}) {
403+
if (message == null) return;
404+
lastReportedError = '$message\n$details';
405+
}
406+
// This overrides the function globally, but we don't need to worry about
407+
// leaking the state because that is only relevant to widget tests.
408+
// See `lib/log.dart ` for details.
409+
reportErrorToUserBriefly = logAndReportErrorToUserBriefly;
410+
394411
test('handles expired queue', () => awaitFakeAsync((async) async {
395412
await prepareStore();
396413
updateMachine.debugPauseLoop();
@@ -428,19 +445,41 @@ void main() {
428445
}));
429446

430447
group('retries on errors', () {
431-
void checkRetry(void Function() prepareError) {
448+
void checkRetry(void Function() prepareError, {
449+
int expectedFailureCountNotifyThreshold = 0,
450+
}) {
451+
final expectedErrorMessage =
452+
'Error connecting to Zulip. Retrying…\n'
453+
'Error connecting to Zulip at ${eg.realmUrl.origin}. Will retry';
454+
432455
awaitFakeAsync((async) async {
433456
await prepareStore(lastEventId: 1);
434457
updateMachine.debugPauseLoop();
435458
updateMachine.poll();
436459
check(async.pendingTimers).length.equals(0);
437460

438-
// Make the request, inducing an error in it.
439-
prepareError();
440-
updateMachine.debugAdvanceLoop();
441-
async.elapse(Duration.zero);
442-
checkLastRequest(lastEventId: 1);
443-
check(store).isLoading.isTrue();
461+
// Need to add 1 to the upperbound for that one additional request
462+
// to trigger error reporting.
463+
for (int i = 0; i < expectedFailureCountNotifyThreshold + 1; i++) {
464+
// Make the request, inducing an error in it.
465+
prepareError();
466+
if (i > 0) {
467+
// End polling backoff from the previous iteration.
468+
async.flushTimers();
469+
}
470+
updateMachine.debugAdvanceLoop();
471+
check(lastReportedError).isNull();
472+
async.elapse(Duration.zero);
473+
if (i < expectedFailureCountNotifyThreshold) {
474+
// The error message should not appear until the `updateMachine`
475+
// has retried the given number of times.
476+
check(takeLastReportedError()).isNull();
477+
} else {
478+
check(takeLastReportedError()).isNotNull().contains(expectedErrorMessage);
479+
}
480+
checkLastRequest(lastEventId: 1);
481+
check(store).isLoading.isTrue();
482+
}
444483

445484
// Polling doesn't resume immediately; there's a timer.
446485
check(async.pendingTimers).length.equals(1);
@@ -461,11 +500,13 @@ void main() {
461500
}
462501

463502
test('Server5xxException', () {
464-
checkRetry(() => connection.prepare(httpStatus: 500, body: 'splat'));
503+
checkRetry(() => connection.prepare(httpStatus: 500, body: 'splat'),
504+
expectedFailureCountNotifyThreshold: 5);
465505
});
466506

467507
test('NetworkException', () {
468-
checkRetry(() => connection.prepare(exception: Exception("failed")));
508+
checkRetry(() => connection.prepare(exception: Exception("failed")),
509+
expectedFailureCountNotifyThreshold: 5);
469510
});
470511

471512
test('ZulipApiException', () {

0 commit comments

Comments
 (0)