Skip to content

store: Handle invalid API key on register-queue #1183

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Feb 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,8 @@
"url": {"type": "String", "example": "http://example.com/"}
}
},
"errorLoginCouldNotConnectTitle": "Could not connect",
"@errorLoginCouldNotConnectTitle": {
"errorCouldNotConnectTitle": "Could not connect",
"@errorCouldNotConnectTitle": {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l10n [nfc]: Use a generalize name for errorCouldNotConnectTitle

commit-message nit: "generalized"

"description": "Error title when the app could not connect to the server."
},
"errorMessageDoesNotSeemToExist": "That message does not seem to exist.",
Expand Down Expand Up @@ -523,6 +523,13 @@
"@topicValidationErrorMandatoryButEmpty": {
"description": "Topic validation error when topic is required but was empty."
},
"errorInvalidApiKeyMessage": "Your account at {url} could not be authenticated. Please try logging in again or use another account.",
"@errorInvalidApiKeyMessage": {
"description": "Error message in the dialog for invalid API key.",
"placeholders": {
"url": {"type": "String", "example": "http://chat.example.com/"}
}
},
"errorInvalidResponse": "The server sent an invalid response",
"@errorInvalidResponse": {
"description": "Error message when an API call returned an invalid response."
Expand Down
8 changes: 7 additions & 1 deletion lib/generated/l10n/zulip_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ abstract class ZulipLocalizations {
///
/// In en, this message translates to:
/// **'Could not connect'**
String get errorLoginCouldNotConnectTitle;
String get errorCouldNotConnectTitle;

/// Error message when loading a message that does not exist.
///
Expand Down Expand Up @@ -801,6 +801,12 @@ abstract class ZulipLocalizations {
/// **'Topics are required in this organization.'**
String get topicValidationErrorMandatoryButEmpty;

/// Error message in the dialog for invalid API key.
///
/// In en, this message translates to:
/// **'Your account at {url} could not be authenticated. Please try logging in again or use another account.'**
String errorInvalidApiKeyMessage(String url);

/// Error message when an API call returned an invalid response.
///
/// In en, this message translates to:
Expand Down
7 changes: 6 additions & 1 deletion lib/generated/l10n/zulip_localizations_ar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
}

@override
String get errorLoginCouldNotConnectTitle => 'Could not connect';
String get errorCouldNotConnectTitle => 'Could not connect';

@override
String get errorMessageDoesNotSeemToExist => 'That message does not seem to exist.';
Expand Down Expand Up @@ -404,6 +404,11 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
@override
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';

@override
String errorInvalidApiKeyMessage(String url) {
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
}

@override
String get errorInvalidResponse => 'The server sent an invalid response';

Expand Down
7 changes: 6 additions & 1 deletion lib/generated/l10n/zulip_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
}

@override
String get errorLoginCouldNotConnectTitle => 'Could not connect';
String get errorCouldNotConnectTitle => 'Could not connect';

@override
String get errorMessageDoesNotSeemToExist => 'That message does not seem to exist.';
Expand Down Expand Up @@ -404,6 +404,11 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
@override
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';

@override
String errorInvalidApiKeyMessage(String url) {
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
}

@override
String get errorInvalidResponse => 'The server sent an invalid response';

Expand Down
7 changes: 6 additions & 1 deletion lib/generated/l10n/zulip_localizations_ja.dart
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
}

@override
String get errorLoginCouldNotConnectTitle => 'Could not connect';
String get errorCouldNotConnectTitle => 'Could not connect';

@override
String get errorMessageDoesNotSeemToExist => 'That message does not seem to exist.';
Expand Down Expand Up @@ -404,6 +404,11 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
@override
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';

@override
String errorInvalidApiKeyMessage(String url) {
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
}

@override
String get errorInvalidResponse => 'The server sent an invalid response';

Expand Down
7 changes: 6 additions & 1 deletion lib/generated/l10n/zulip_localizations_nb.dart
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
}

@override
String get errorLoginCouldNotConnectTitle => 'Could not connect';
String get errorCouldNotConnectTitle => 'Could not connect';

@override
String get errorMessageDoesNotSeemToExist => 'That message does not seem to exist.';
Expand Down Expand Up @@ -404,6 +404,11 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
@override
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';

@override
String errorInvalidApiKeyMessage(String url) {
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
}

@override
String get errorInvalidResponse => 'The server sent an invalid response';

Expand Down
7 changes: 6 additions & 1 deletion lib/generated/l10n/zulip_localizations_pl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
}

@override
String get errorLoginCouldNotConnectTitle => 'Nie można połączyć';
String get errorCouldNotConnectTitle => 'Could not connect';

@override
String get errorMessageDoesNotSeemToExist => 'Taka wiadomość raczej nie istnieje.';
Expand Down Expand Up @@ -404,6 +404,11 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
@override
String get topicValidationErrorMandatoryButEmpty => 'Wątki są wymagane przez tę organizację.';

@override
String errorInvalidApiKeyMessage(String url) {
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
}

@override
String get errorInvalidResponse => 'Nieprawidłowa odpowiedź serwera';

Expand Down
7 changes: 6 additions & 1 deletion lib/generated/l10n/zulip_localizations_ru.dart
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
}

@override
String get errorLoginCouldNotConnectTitle => 'Не удалось подключиться';
String get errorCouldNotConnectTitle => 'Could not connect';

@override
String get errorMessageDoesNotSeemToExist => 'Это сообщение, похоже, отсутствует.';
Expand Down Expand Up @@ -404,6 +404,11 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
@override
String get topicValidationErrorMandatoryButEmpty => 'Темы обязательны в этой организации.';

@override
String errorInvalidApiKeyMessage(String url) {
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
}

@override
String get errorInvalidResponse => 'Получен недопустимый ответ сервера';

Expand Down
7 changes: 6 additions & 1 deletion lib/generated/l10n/zulip_localizations_sk.dart
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
}

@override
String get errorLoginCouldNotConnectTitle => 'Nepodarilo sa pripojiť';
String get errorCouldNotConnectTitle => 'Could not connect';

@override
String get errorMessageDoesNotSeemToExist => 'Správa zrejme neexistuje.';
Expand Down Expand Up @@ -404,6 +404,11 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
@override
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';

@override
String errorInvalidApiKeyMessage(String url) {
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
}

@override
String get errorInvalidResponse => 'Server poslal nesprávnu odpoveď';

Expand Down
30 changes: 27 additions & 3 deletions lib/log.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ bool debugLog(String message) {
return true;
}

typedef ReportErrorCallback = void Function(String? message, {String? details});
// This should only be used for error reporting functions that allow the error
// to be cancelled programmatically. The implementation is expected to handle
// `null` for the `message` parameter and promptly dismiss the reported errors.
typedef ReportErrorCancellablyCallback = void Function(String? message, {String? details});

typedef ReportErrorCallback = void Function(String title, {String? message});

/// Show the user an error message, without requiring them to interact with it.
///
Expand All @@ -48,10 +53,29 @@ typedef ReportErrorCallback = void Function(String? message, {String? details});
// This gets set in [ZulipApp]. We need this indirection to keep `lib/log.dart`
// from importing widget code, because the file is a dependency for the rest of
// the app.
ReportErrorCallback reportErrorToUserBriefly = defaultReportErrorToUserBriefly;
ReportErrorCancellablyCallback reportErrorToUserBriefly = defaultReportErrorToUserBriefly;
Comment on lines -51 to +56
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

log [nfc]: Rename ReportErrorCallback to ReportErrorCancellablyCallback

This highlights the API choice that the callback signature allows the
caller to clear/cancel the reported errors, drawing distinction from a
later added variant that does not allow this.

Looking closer at this commit, I don't think the "cancellably" distinction is very clear. The commit message says a later-added variant doesn't allow a caller to "clear/cancel" the reported errors, but that sounds wrong: that variant, reportErrorToUserModally, gives "dismissable" UI (quote from dartdoc). It's an error dialog that offers an "OK" button to make it go away.

Is there an important distinction to make?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. I think it was not clear without additional comments that this is describing a callback that accepts null for message when the error needs to be programmatically dismissed, instead of something UI specific.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I think I understand now: reportErrorToUserBriefly(null) is a way to clear the snackbars programmatically.


/// Show the user a dismissable error message in a modal popup.
///
/// Typically this shows an [AlertDialog] with `title` as the title, `message`
/// as the body. If called before the app's widget tree is ready
/// (see [ZulipApp.ready]), then we give up on showing the message to the user,
/// and just log the message to the console.
// This gets set in [ZulipApp]. We need this indirection to keep `lib/log.dart`
// from importing widget code, because the file is a dependency for the rest of
// the app.
ReportErrorCallback reportErrorToUserModally = defaultReportErrorToUserModally;

void defaultReportErrorToUserBriefly(String? message, {String? details}) {
// Error dismissing is a no-op to the default handler.
_reportErrorToConsole(message, details);
}

void defaultReportErrorToUserModally(String title, {String? message}) {
_reportErrorToConsole(title, message);
}

void _reportErrorToConsole(String? message, String? details) {
// Error dismissing is a no-op for the console.
if (message == null) return;
// If this callback is still in place, then the app's widget tree
// hasn't mounted yet even as far as the [Navigator].
Expand Down
59 changes: 51 additions & 8 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import '../api/backoff.dart';
import '../api/route/realm.dart';
import '../log.dart';
import '../notifications/receive.dart';
import 'actions.dart';
import 'autocomplete.dart';
import 'database.dart';
import 'emoji.dart';
Expand Down Expand Up @@ -149,8 +150,36 @@ abstract class GlobalStore extends ChangeNotifier {
/// and/or [perAccountSync].
Future<PerAccountStore> loadPerAccount(int accountId) async {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit in commit message:

The method loadPerAccount has two call sites, i.e. places
where we send register-queue requests:

1. _reloadPerAccount through [UpdateMachine._handlePollError]
2. perAccount through [PerAccountStoreWidget] (the common case)

I wouldn't call either of these "the common case" (which implies all other cases are rare or edge cases) — I think both of these are common. When I open the app it quite often was already in the background, but had been for more than 10 minutes, and that hits case 1.

assert(_accounts.containsKey(accountId));
final store = await doLoadPerAccount(accountId);
final PerAccountStore store;
try {
store = await doLoadPerAccount(accountId);
} catch (e) {
switch (e) {
case HttpException(httpStatus: 401):
// The API key is invalid and the store can never be loaded
// unless the user retries manually.
Comment on lines +158 to +160
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's raise a thread on #api design asking for feedback on this condition httpStatus: 401. It seems quite reasonable, but it'd be good to make sure that everyone's on the same page about the semantics we'll be counting on from the server.

(For most things in the API we just work from the API docs with no further consultation — but this is an area of the API that's not clearly documented. The closest is https://chat.zulip.org/api/rest-error-handling , but for most errors that doesn't specify HTTP status codes, and indeed it specifies a Zulip API error code of INVALID_API_KEY which we learned at #1183 (comment) isn't what the server actually produces in the most obvious case of an API key being invalid.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CZO conversation

From that thread, we confirmed that checking for 401 is the desired approach.

final account = getAccount(accountId);
if (account == null) {
// The account was logged out during `await doLoadPerAccount`.
// Here, that seems possible only by the user's own action;
// the logout can't have been done programmatically.
// Even if it were, it would have come with its own UI feedback.
// Anyway, skip showing feedback, to not be confusing or repetitive.
throw AccountNotFoundException();
}
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
reportErrorToUserModally(
zulipLocalizations.errorCouldNotConnectTitle,
message: zulipLocalizations.errorInvalidApiKeyMessage(
account.realmUrl.toString()));
await logOutAccount(this, accountId);
throw AccountNotFoundException();
default:
rethrow;
}
}
if (!_accounts.containsKey(accountId)) {
// TODO(#1354): handle this earlier
// [removeAccount] was called during [doLoadPerAccount].
store.dispose();
throw AccountNotFoundException();
Expand Down Expand Up @@ -913,12 +942,19 @@ class UpdateMachine {
try {
return await registerQueue(connection);
} catch (e, s) {
assert(debugLog('Error fetching initial snapshot: $e'));
// Print stack trace in its own log entry; log entries are truncated
// at 1 kiB (at least on Android), and stack can be longer than that.
assert(debugLog('Stack:\n$s'));
// TODO(#890): tell user if initial-fetch errors persist, or look non-transient
switch (e) {
case HttpException(httpStatus: 401):
// We cannot recover from this error through retrying.
// Leave it to [GlobalStore.loadPerAccount].
rethrow;
default:
assert(debugLog('Error fetching initial snapshot: $e'));
// Print stack trace in its own log entry; log entries are truncated
// at 1 kiB (at least on Android), and stack can be longer than that.
assert(debugLog('Stack:\n$s'));
}
assert(debugLog('Backing off, then will retry…'));
// TODO tell user if initial-fetch errors persist, or look non-transient
await (backoffMachine ??= BackoffMachine()).wait();
assert(debugLog('… Backoff wait complete, retrying initial fetch.'));
}
Expand Down Expand Up @@ -1177,6 +1213,7 @@ class UpdateMachine {
store.isLoading = true;

bool isUnexpected;
// TODO(#1054): handle auth failure
switch (error) {
case ZulipApiException(code: 'BAD_EVENT_QUEUE_ID'):
assert(debugLog('Lost event queue for $store. Replacing…'));
Expand Down Expand Up @@ -1218,8 +1255,14 @@ class UpdateMachine {
if (_disposed) return;
}

await store._globalStore._reloadPerAccount(store.accountId);
assert(_disposed);
try {
await store._globalStore._reloadPerAccount(store.accountId);
} on AccountNotFoundException {
Comment on lines +1258 to +1260
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this try/catch something we should have even before the new handling of auth errors?

I think it is: suppose the app loses its event queue and goes to reload initial data, and while it's doing that the user navigates to the choose-account page and logs out the account. Then I think loadPerAccount would hit its existing throw AccountNotFoundException line.

So this can be moved to a separate prep commit. I think doing that will also help simplify the explanation in the main commit's commit message.

Copy link
Member Author

@PIG208 PIG208 Feb 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found it pretty hard to hit this existing throw AccountNotFoundException line with TestGlobalStore or LiveGlobalStore, as we have an assertion at the start of PerAccountStore.fromInitialSnapshot:

    final account = globalStore.getAccount(accountId)!;
[ERROR:flutter/runtime/dart_vm_initializer.cc(40)] Unhandled Exception: Null check operator used on a null value
#0      new PerAccountStore.fromInitialSnapshot (package:zulip/model/store.dart:285:54)
#1      UpdateMachine.load (package:zulip/model/store.dart:913:35)
<asynchronous suspension>
#2      LiveGlobalStore.doLoadPerAccount (package:zulip/model/store.dart:836:27)
<asynchronous suspension>
#3      GlobalStore.loadPerAccount (package:zulip/model/store.dart:155:15)
<asynchronous suspension>
#4      GlobalStore._reloadPerAccount (package:zulip/model/store.dart:135:19)

For both implementations, PerAccountStore.fromInitialSnapshot is called at the end with no asynchronous gap, meaning that the null-check will always fail ahead of AccountNotFoundException being raised, if the user logs out during the asynchronous gap from the /api/register-queue request or globalStore.updateAccount:

  static Future<UpdateMachine> load(GlobalStore globalStore, int accountId) async {
    Account account = globalStore.getAccount(accountId)!;
    final connection = globalStore.apiConnectionFromAccount(account);

    final stopwatch = Stopwatch()..start();
    final initialSnapshot = await _registerQueueWithRetry(connection);
    final t = (stopwatch..stop()).elapsed;
    assert(debugLog("initial fetch time: ${t.inMilliseconds}ms"));

    if (initialSnapshot.zulipVersion != account.zulipVersion
        || initialSnapshot.zulipMergeBase != account.zulipMergeBase
        || initialSnapshot.zulipFeatureLevel != account.zulipFeatureLevel) {
      account = await globalStore.updateAccount(accountId, AccountsCompanion(
        zulipVersion: Value(initialSnapshot.zulipVersion),
        zulipMergeBase: Value(initialSnapshot.zulipMergeBase),
        zulipFeatureLevel: Value(initialSnapshot.zulipFeatureLevel),
      ));
      connection.zulipFeatureLevel = initialSnapshot.zulipFeatureLevel;
    }

    final store = PerAccountStore.fromInitialSnapshot(
      globalStore: globalStore,
      accountId: accountId,
      connection: connection,
      initialSnapshot: initialSnapshot,
    );
    final updateMachine = UpdateMachine.fromInitialSnapshot(
      store: store, initialSnapshot: initialSnapshot);
    updateMachine.poll();
    if (initialSnapshot.serverEmojiDataUrl != null) {
      // TODO(server-6): If the server is ancient, just skip trying to have
      //   a list of its emoji.  (The old servers that don't provide
      //   serverEmojiDataUrl are already unsupported at time of writing.)
      unawaited(updateMachine.fetchEmojiData(initialSnapshot.serverEmojiDataUrl!));
    }
    // TODO do registerNotificationToken before registerQueue:
    //   https://github.com/zulip/zulip-flutter/pull/325#discussion_r1365982807
    unawaited(updateMachine.registerNotificationToken());
    return updateMachine;
  }

I think this is a bug that the if (!_accounts.containsKey(accountId)) check was meant to catch. The error should be thrown earlier, ideally every time before the account is accessed, similar to the if (mounted) checks and BuildContext.

Modulo the bug, it would be helpful to have the try/catch here, except that it is unreachable until later.

Opened #1183 for this.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened #1183 for this.

Did you mean a different issue/PR number here? That number refers to the current PR.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah. It should be #1354.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, thanks — as discussed on #1354, I agree that there should be a check earlier that throws AccountNotFoundException.

For exercising this case in a test (before the main changes at the tip of this PR), see new comment below: #1183 (comment)

assert(debugLog('… Event queue not replaced; account was logged out.'));
return;
} finally {
assert(_disposed);
}
assert(debugLog('… Event queue replaced.'));
}

Expand Down
Loading