Skip to content

Commit ac2a6ad

Browse files
committed
store: Report polling errors with details
We extend the checkRetry helper to support simulating multiple consecutive errors. We delay reporting transient errors until we have retried a certain number of times, because they might recover on their own. Signed-off-by: Zixuan James Li <[email protected]>
1 parent bca8cd1 commit ac2a6ad

File tree

3 files changed

+109
-13
lines changed

3 files changed

+109
-13
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: 32 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';
@@ -771,8 +772,25 @@ class UpdateMachine {
771772
}());
772773
}
773774

775+
/// This controls when we start to report transient errors to the user when
776+
/// polling.
777+
///
778+
/// At the 6th failure, the expected time elapsed since the first failure
779+
/// will be 1.55 seocnds.
780+
static const transientFailureCountNotifyThreshold = 5;
781+
774782
void poll() async {
775783
BackoffMachine? backoffMachine;
784+
int accumulatedTransientFailureCount = 0;
785+
786+
/// This only reports transient errors after reaching
787+
/// a pre-defined threshold of retries.
788+
void maybeReportTransientError(String? message, {String? details}) {
789+
accumulatedTransientFailureCount++;
790+
if (accumulatedTransientFailureCount > transientFailureCountNotifyThreshold) {
791+
reportErrorToUserBriefly(message, details: details);
792+
}
793+
}
776794

777795
while (true) {
778796
if (_debugLoopSignal != null) {
@@ -789,6 +807,8 @@ class UpdateMachine {
789807
queueId: queueId, lastEventId: lastEventId);
790808
} catch (e) {
791809
store.isLoading = true;
810+
final localizations = GlobalLocalizations.zulipLocalizations;
811+
final serverUrl = store.connection.realmUrl.origin;
792812
switch (e) {
793813
case ZulipApiException(code: 'BAD_EVENT_QUEUE_ID'):
794814
assert(debugLog('Lost event queue for $store. Replacing…'));
@@ -800,15 +820,22 @@ class UpdateMachine {
800820
case Server5xxException() || NetworkException():
801821
assert(debugLog('Transient error polling event queue for $store: $e\n'
802822
'Backing off, then will retry…'));
803-
// TODO tell user if transient polling errors persist
823+
maybeReportTransientError(
824+
localizations.errorConnectingToServerShort,
825+
details: localizations.errorConnectingToServerDetails(
826+
serverUrl, e.toString()));
804827
await (backoffMachine ??= BackoffMachine()).wait();
805828
assert(debugLog('… Backoff wait complete, retrying poll.'));
806829
continue;
807830

808831
default:
809832
assert(debugLog('Error polling event queue for $store: $e\n'
810833
'Backing off and retrying even though may be hopeless…'));
811-
// TODO tell user on non-transient error in polling
834+
// TODO(#186): Handle unrecoverable failures
835+
reportErrorToUserBriefly(
836+
localizations.errorConnectingToServerShort,
837+
details: localizations.errorConnectingToServerDetails(
838+
serverUrl, e.toString()));
812839
await (backoffMachine ??= BackoffMachine()).wait();
813840
assert(debugLog('… Backoff wait complete, retrying poll.'));
814841
continue;
@@ -832,6 +859,9 @@ class UpdateMachine {
832859
// and failures, the successes themselves should space out the requests.
833860
backoffMachine = null;
834861
store.isLoading = false;
862+
// Dismiss existing errors, if any.
863+
reportErrorToUserBriefly(null);
864+
accumulatedTransientFailureCount = 0;
835865

836866
final events = result.events;
837867
for (final event in events) {

test/model/store_test.dart

Lines changed: 65 additions & 11 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,22 @@ 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+
/// This is an alternative to [ZulipApp]'s implementation of
405+
/// [reportErrorToUserBriefly] for testing.
406+
Future<void> logAndReportErrorToUserBriefly(String? message, {
407+
String? details,
408+
}) async {
409+
if (message == null) return;
410+
lastReportedError = '$message\n$details';
411+
}
412+
396413
test('handles expired queue', () => awaitFakeAsync((async) async {
397414
await prepareStore();
398415
updateMachine.debugPauseLoop();
@@ -456,19 +473,52 @@ void main() {
456473
}));
457474

458475
group('retries on errors', () {
459-
void checkRetry(void Function() prepareError) {
476+
/// Check if [UpdateMachine.poll] retries as expected when there are
477+
/// errors.
478+
///
479+
/// This also verifies that the first user-facing error message appears
480+
/// after the `numFailedRequests`'th failed request.
481+
void checkRetry(void Function() prepareError, {
482+
required int numFailedRequests,
483+
}) {
484+
assert(numFailedRequests > 0);
485+
reportErrorToUserBriefly = logAndReportErrorToUserBriefly;
486+
addTearDown(() => reportErrorToUserBriefly = defaultReportErrorToUserBriefly);
487+
488+
final expectedErrorMessage =
489+
'Error connecting to Zulip. Retrying…\n'
490+
'Error connecting to Zulip at ${eg.realmUrl.origin}. Will retry';
491+
460492
awaitFakeAsync((async) async {
461493
await prepareStore(lastEventId: 1);
462494
updateMachine.debugPauseLoop();
463495
updateMachine.poll();
464496
check(async.pendingTimers).length.equals(0);
465497

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();
498+
for (int i = 0; i < numFailedRequests; i++) {
499+
// Make the request, inducing an error in it.
500+
prepareError();
501+
updateMachine.debugAdvanceLoop();
502+
async.elapse(Duration.zero);
503+
checkLastRequest(lastEventId: 1);
504+
check(store).isLoading.isTrue();
505+
506+
if (i != numFailedRequests - 1) {
507+
// Skip polling backoff unless this is the final iteration.
508+
// This allows the next `updateMachine.debugAdvanceLoop` call to
509+
// trigger the next request without wait.
510+
async.flushTimers();
511+
}
512+
513+
if (i < numFailedRequests - 1) {
514+
// The error message should not appear until the `updateMachine`
515+
// has retried the given number of times.
516+
check(takeLastReportedError()).isNull();
517+
continue;
518+
}
519+
assert(i == numFailedRequests - 1);
520+
check(takeLastReportedError()).isNotNull().contains(expectedErrorMessage);
521+
}
472522

473523
// Polling doesn't resume immediately; there's a timer.
474524
check(async.pendingTimers).length.equals(1);
@@ -489,20 +539,24 @@ void main() {
489539
}
490540

491541
test('Server5xxException', () {
492-
checkRetry(() => connection.prepare(httpStatus: 500, body: 'splat'));
542+
checkRetry(() => connection.prepare(httpStatus: 500, body: 'splat'),
543+
numFailedRequests: UpdateMachine.transientFailureCountNotifyThreshold + 1);
493544
});
494545

495546
test('NetworkException', () {
496-
checkRetry(() => connection.prepare(exception: Exception("failed")));
547+
checkRetry(() => connection.prepare(exception: Exception("failed")),
548+
numFailedRequests: UpdateMachine.transientFailureCountNotifyThreshold + 1);
497549
});
498550

499551
test('ZulipApiException', () {
500552
checkRetry(() => connection.prepare(httpStatus: 400, json: {
501-
'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'Bad request'}));
553+
'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'Bad request'}),
554+
numFailedRequests: 1);
502555
});
503556

504557
test('MalformedServerResponseException', () {
505-
checkRetry(() => connection.prepare(httpStatus: 200, body: 'nonsense'));
558+
checkRetry(() => connection.prepare(httpStatus: 200, body: 'nonsense'),
559+
numFailedRequests: 1);
506560
});
507561
});
508562
});

0 commit comments

Comments
 (0)