Skip to content

Commit a6cb0bc

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 3f66b3e commit a6cb0bc

File tree

3 files changed

+83
-11
lines changed

3 files changed

+83
-11
lines changed

assets/l10n/app_en.arb

+12
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,18 @@
160160
"message": {"type": "String", "example": "Invalid format"}
161161
}
162162
},
163+
"errorConnectingToServerShort": "Error connecting to Zulip. Retrying…",
164+
"@errorConnectingToServerShort": {
165+
"description": "Short error message for a generic unknown error connecting to the server."
166+
},
167+
"errorConnectingToServerDetails": "Error connecting to Zulip at {serverUrl}. Will retry:\n\n{error}",
168+
"@errorConnectingToServerDetails": {
169+
"description": "Dialog error message for a generic unknown error connecting to the 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

+20-2
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.errorConnectingToServerShort,
809+
details: localizations.errorConnectingToServerDetails(
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.errorConnectingToServerShort,
822+
details: localizations.errorConnectingToServerDetails(
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

+51-9
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,20 @@ 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+
Future<void> logAndReportErrorToUserBriefly(String? message, {
403+
String? details,
404+
}) async {
405+
if (message == null) return;
406+
lastReportedError = '$message\n$details';
407+
}
408+
394409
test('handles expired queue', () => awaitFakeAsync((async) async {
395410
await prepareStore();
396411
updateMachine.debugPauseLoop();
@@ -428,19 +443,44 @@ void main() {
428443
}));
429444

430445
group('retries on errors', () {
431-
void checkRetry(void Function() prepareError) {
446+
void checkRetry(void Function() prepareError, {
447+
int expectedFailureCountNotifyThreshold = 0,
448+
}) {
449+
reportErrorToUserBriefly = logAndReportErrorToUserBriefly;
450+
addTearDown(() => reportErrorToUserBriefly = defaultReportErrorToUserBriefly);
451+
452+
final expectedErrorMessage =
453+
'Error connecting to Zulip. Retrying…\n'
454+
'Error connecting to Zulip at ${eg.realmUrl.origin}. Will retry';
455+
432456
awaitFakeAsync((async) async {
433457
await prepareStore(lastEventId: 1);
434458
updateMachine.debugPauseLoop();
435459
updateMachine.poll();
436460
check(async.pendingTimers).length.equals(0);
437461

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

445485
// Polling doesn't resume immediately; there's a timer.
446486
check(async.pendingTimers).length.equals(1);
@@ -461,11 +501,13 @@ void main() {
461501
}
462502

463503
test('Server5xxException', () {
464-
checkRetry(() => connection.prepare(httpStatus: 500, body: 'splat'));
504+
checkRetry(() => connection.prepare(httpStatus: 500, body: 'splat'),
505+
expectedFailureCountNotifyThreshold: 5);
465506
});
466507

467508
test('NetworkException', () {
468-
checkRetry(() => connection.prepare(exception: Exception("failed")));
509+
checkRetry(() => connection.prepare(exception: Exception("failed")),
510+
expectedFailureCountNotifyThreshold: 5);
469511
});
470512

471513
test('ZulipApiException', () {

0 commit comments

Comments
 (0)