Skip to content

Commit c112a3e

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 aaeafd5 commit c112a3e

File tree

3 files changed

+83
-11
lines changed

3 files changed

+83
-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+
"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

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';
@@ -773,6 +774,8 @@ class UpdateMachine {
773774

774775
void poll() async {
775776
BackoffMachine? backoffMachine;
777+
int accumulatedTransientFailureCount = 0;
778+
const transientFailureCountNotifyThreshold = 5;
776779

777780
while (true) {
778781
if (_debugLoopSignal != null) {
@@ -789,6 +792,8 @@ class UpdateMachine {
789792
queueId: queueId, lastEventId: lastEventId);
790793
} catch (e) {
791794
store.isLoading = true;
795+
final localizations = GlobalLocalizations.zulipLocalizations;
796+
final serverUrl = store.connection.realmUrl.origin;
792797
switch (e) {
793798
case ZulipApiException(code: 'BAD_EVENT_QUEUE_ID'):
794799
assert(debugLog('Lost event queue for $store. Replacing…'));
@@ -800,15 +805,25 @@ class UpdateMachine {
800805
case Server5xxException() || NetworkException():
801806
assert(debugLog('Transient error polling event queue for $store: $e\n'
802807
'Backing off, then will retry…'));
803-
// TODO tell user if transient polling errors persist
808+
accumulatedTransientFailureCount++;
809+
if (accumulatedTransientFailureCount > transientFailureCountNotifyThreshold) {
810+
reportErrorToUserBriefly(
811+
localizations.errorConnectingToServerShort,
812+
details: localizations.errorConnectingToServerDetails(
813+
serverUrl, e.toString()));
814+
}
804815
await (backoffMachine ??= BackoffMachine()).wait();
805816
assert(debugLog('… Backoff wait complete, retrying poll.'));
806817
continue;
807818

808819
default:
809820
assert(debugLog('Error polling event queue for $store: $e\n'
810821
'Backing off and retrying even though may be hopeless…'));
811-
// TODO tell user on non-transient error in polling
822+
// TODO(#186): Handle unrecoverable failures
823+
reportErrorToUserBriefly(
824+
localizations.errorConnectingToServerShort,
825+
details: localizations.errorConnectingToServerDetails(
826+
serverUrl, e.toString()));
812827
await (backoffMachine ??= BackoffMachine()).wait();
813828
assert(debugLog('… Backoff wait complete, retrying poll.'));
814829
continue;
@@ -832,6 +847,9 @@ class UpdateMachine {
832847
// and failures, the successes themselves should space out the requests.
833848
backoffMachine = null;
834849
store.isLoading = false;
850+
// Dismiss existing errors, if any.
851+
reportErrorToUserBriefly(null);
852+
accumulatedTransientFailureCount = 0;
835853

836854
final events = result.events;
837855
for (final event in events) {

test/model/store_test.dart

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import 'package:zulip/api/route/events.dart';
1010
import 'package:zulip/api/route/messages.dart';
1111
import 'package:zulip/model/message_list.dart';
1212
import 'package:zulip/model/narrow.dart';
13+
import 'package:zulip/log.dart';
1314
import 'package:zulip/model/store.dart';
1415
import 'package:zulip/notifications/receive.dart';
1516

@@ -393,6 +394,20 @@ void main() {
393394
check(store.userSettings!.twentyFourHourTime).isTrue();
394395
}));
395396

397+
String? lastReportedError;
398+
String? takeLastReportedError() {
399+
final result = lastReportedError;
400+
lastReportedError = null;
401+
return result;
402+
}
403+
404+
Future<void> logAndReportErrorToUserBriefly(String? message, {
405+
String? details,
406+
}) async {
407+
if (message == null) return;
408+
lastReportedError = '$message\n$details';
409+
}
410+
396411
test('handles expired queue', () => awaitFakeAsync((async) async {
397412
await prepareStore();
398413
updateMachine.debugPauseLoop();
@@ -456,19 +471,44 @@ void main() {
456471
}));
457472

458473
group('retries on errors', () {
459-
void checkRetry(void Function() prepareError) {
474+
void checkRetry(void Function() prepareError, {
475+
int expectedFailureCountNotifyThreshold = 0,
476+
}) {
477+
reportErrorToUserBriefly = logAndReportErrorToUserBriefly;
478+
addTearDown(() => reportErrorToUserBriefly = defaultReportErrorToUserBriefly);
479+
480+
final expectedErrorMessage =
481+
'Error connecting to Zulip. Retrying…\n'
482+
'Error connecting to Zulip at ${eg.realmUrl.origin}. Will retry';
483+
460484
awaitFakeAsync((async) async {
461485
await prepareStore(lastEventId: 1);
462486
updateMachine.debugPauseLoop();
463487
updateMachine.poll();
464488
check(async.pendingTimers).length.equals(0);
465489

466-
// Make the request, inducing an error in it.
467-
prepareError();
468-
updateMachine.debugAdvanceLoop();
469-
async.elapse(Duration.zero);
470-
checkLastRequest(lastEventId: 1);
471-
check(store).isLoading.isTrue();
490+
// Need to add 1 to the upperbound for that one additional request
491+
// to trigger error reporting.
492+
for (int i = 0; i < expectedFailureCountNotifyThreshold + 1; i++) {
493+
// Make the request, inducing an error in it.
494+
prepareError();
495+
if (i > 0) {
496+
// End polling backoff from the previous iteration.
497+
async.flushTimers();
498+
}
499+
updateMachine.debugAdvanceLoop();
500+
check(lastReportedError).isNull();
501+
async.elapse(Duration.zero);
502+
if (i < expectedFailureCountNotifyThreshold) {
503+
// The error message should not appear until the `updateMachine`
504+
// has retried the given number of times.
505+
check(takeLastReportedError()).isNull();
506+
} else {
507+
check(takeLastReportedError()).isNotNull().contains(expectedErrorMessage);
508+
}
509+
checkLastRequest(lastEventId: 1);
510+
check(store).isLoading.isTrue();
511+
}
472512

473513
// Polling doesn't resume immediately; there's a timer.
474514
check(async.pendingTimers).length.equals(1);
@@ -489,11 +529,13 @@ void main() {
489529
}
490530

491531
test('Server5xxException', () {
492-
checkRetry(() => connection.prepare(httpStatus: 500, body: 'splat'));
532+
checkRetry(() => connection.prepare(httpStatus: 500, body: 'splat'),
533+
expectedFailureCountNotifyThreshold: 5);
493534
});
494535

495536
test('NetworkException', () {
496-
checkRetry(() => connection.prepare(exception: Exception("failed")));
537+
checkRetry(() => connection.prepare(exception: Exception("failed")),
538+
expectedFailureCountNotifyThreshold: 5);
497539
});
498540

499541
test('ZulipApiException', () {

0 commit comments

Comments
 (0)