unify errors, refactor retry_queue

Signed-off-by: Sahil Kumar <[email protected]>
This commit is contained in:
Sahil Kumar
2021-06-02 17:56:38 +05:30
parent 927eb3c451
commit 12e6b9c500
18 changed files with 486 additions and 381 deletions
+24 -18
View File
@@ -7,6 +7,8 @@ import 'package:dio/dio.dart';
import 'package:logging/logging.dart';
import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/src/api/retry_queue.dart';
import 'package:stream_chat/src/core/utils.dart';
import 'package:stream_chat/src/errors/stream_chat_error.dart';
import 'package:stream_chat/src/event_type.dart';
import 'package:stream_chat/src/extensions/rate_limit.dart';
import 'package:stream_chat/src/core/models/attachment_file.dart';
@@ -233,12 +235,14 @@ class Channel {
}) {
final cancelToken = _cancelableAttachmentUploadRequest[attachmentId];
if (cancelToken == null) {
throw Exception(
"Upload request for this Attachment hasn't started yet or else "
throw const StreamChatError(
"Upload request for this Attachment hasn't started yet or maybe "
'Already completed',
);
}
if (cancelToken.isCancelled) throw Exception('Already cancelled');
if (cancelToken.isCancelled) {
throw const StreamChatError('Upload request already cancelled');
}
cancelToken.cancel(reason);
}
@@ -255,7 +259,7 @@ class Channel {
);
if (message == null) {
throw Exception('Error, Message not found');
throw const StreamChatError('Error, Message not found');
}
final attachments = message.attachments.where((it) {
@@ -396,9 +400,9 @@ class Channel {
final response = await _client.sendMessage(message, id!, type);
state!.addMessage(response.message);
return response;
} catch (error) {
if (error is DioError && error.type != DioErrorType.response) {
state!.retryQueue?.add([message]);
} catch (e) {
if (e is StreamChatNetworkError && e.isRetriable) {
state!._retryQueue.add([message]);
}
rethrow;
}
@@ -453,9 +457,9 @@ class Channel {
state?.addMessage(m);
return response;
} catch (error) {
if (error is DioError && error.type != DioErrorType.response) {
state?.retryQueue?.add([message]);
} catch (e) {
if (e is StreamChatNetworkError && e.isRetriable) {
state!._retryQueue.add([message]);
}
rethrow;
}
@@ -494,9 +498,9 @@ class Channel {
state?.addMessage(message.copyWith(status: MessageSendingStatus.sent));
return response;
} catch (error) {
if (error is DioError && error.type != DioErrorType.response) {
state?.retryQueue?.add([message]);
} catch (e) {
if (e is StreamChatNetworkError && e.isRetriable) {
state!._retryQueue.add([message]);
}
rethrow;
}
@@ -1188,9 +1192,11 @@ class ChannelClientState {
_channel._client.chatPersistenceClient
?.updateChannelState(state))
.debounced(const Duration(seconds: 1)) {
retryQueue = RetryQueue(
_retryQueue = RetryQueue(
channel: _channel,
logger: Logger('RETRY QUEUE ${_channel.cid}'),
logger: _channel.client.detachedLogger(
'⟳ (${generateHash([_channel.cid])})',
),
);
_checkExpiredAttachmentMessages(channelState);
@@ -1338,7 +1344,7 @@ class ChannelClientState {
BehaviorSubject.seeded(true);
/// The retry queue associated to this channel
RetryQueue? retryQueue;
late final RetryQueue _retryQueue;
/// Retry failed message
Future<void> retryFailedMessages() async {
@@ -1357,7 +1363,7 @@ class ChannelClientState {
)
.toList();
retryQueue!.add(failedMessages);
_retryQueue.add(failedMessages);
}
void _listenReactionDeleted() {
@@ -1832,7 +1838,7 @@ class ChannelClientState {
void dispose() {
_debouncedUpdatePersistenceChannelState.cancel();
_unreadCountController.close();
retryQueue!.dispose();
_retryQueue.dispose();
_subscriptions.forEach((s) => s.cancel());
_channelStateController.close();
_isUpToDateController.close();
@@ -1,5 +1,5 @@
import 'package:stream_chat/src/client.dart';
import 'package:stream_chat/src/exceptions.dart';
import 'package:stream_chat/src/errors/stream_chat_error.dart';
/// The retry options
class RetryPolicy {
@@ -7,32 +7,25 @@ class RetryPolicy {
RetryPolicy({
required this.shouldRetry,
required this.retryTimeout,
this.attempt = 0,
this.maxRetryAttempts = 6,
});
/// The number of attempts tried so far
int attempt = 0;
/// Hard limit on maximum retry attempts before giving up, defaults to 6
/// Resets once connection recovers.
final int maxRetryAttempts;
/// This function evaluates if we should retry the failure
final bool Function(StreamChatClient client, int attempt, ApiError? apiError)
shouldRetry;
final bool Function(
StreamChatClient client,
int attempt,
StreamChatError? error,
) shouldRetry;
/// In the case that we want to retry a failed request the retryTimeout
/// method is called to determine the timeout
final Duration Function(
StreamChatClient client, int attempt, ApiError? apiError) retryTimeout;
/// Creates a copy of [RetryPolicy] with specified attributes overridden.
RetryPolicy copyWith({
bool Function(StreamChatClient client, int attempt, ApiError? apiError)?
shouldRetry,
Duration Function(StreamChatClient client, int attempt, ApiError? apiError)?
retryTimeout,
int? attempt,
}) =>
RetryPolicy(
retryTimeout: retryTimeout ?? this.retryTimeout,
shouldRetry: shouldRetry ?? this.shouldRetry,
attempt: attempt ?? this.attempt,
);
StreamChatClient client,
int attempt,
StreamChatError? error,
) retryTimeout;
}
+143 -108
View File
@@ -2,10 +2,11 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:logging/logging.dart';
import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/src/api/channel.dart';
import 'package:stream_chat/src/api/retry_policy.dart';
import 'package:stream_chat/src/errors/stream_chat_error.dart';
import 'package:stream_chat/src/event_type.dart';
import 'package:stream_chat/src/exceptions.dart';
import 'package:stream_chat/src/core/models/message.dart';
import 'package:stream_chat/stream_chat.dart';
@@ -15,116 +16,155 @@ class RetryQueue {
RetryQueue({
required this.channel,
this.logger,
}) {
_retryPolicy = channel.client.retryPolicy;
}) : client = channel.client {
_retryPolicy = client.retryPolicy;
_listenConnectionRecovered();
_listenFailedEvents();
}
/// The channel of this queue
final Channel channel;
/// The client associated with this [channel]
final StreamChatClient client;
/// The logger associated to this queue
final Logger? logger;
final _subscriptions = <StreamSubscription>[];
late final RetryPolicy _retryPolicy;
final _compositeSubscription = CompositeSubscription();
final _messageQueue = HeapPriorityQueue(_byDate);
bool _isRetrying = false;
void _listenConnectionRecovered() {
_subscriptions
.add(channel.client.on(EventType.connectionRecovered).listen((event) {
if (!_isRetrying && event.online!) {
client.on(EventType.connectionRecovered).listen((event) {
if (event.online == true) {
_startRetrying();
}
}));
}).addTo(_compositeSubscription);
}
final HeapPriorityQueue<Message> _messageQueue = HeapPriorityQueue(_byDate);
bool _isRetrying = false;
RetryPolicy? _retryPolicy;
void _listenFailedEvents() {
channel.on().where((event) => event.message != null).listen((event) {
final message = event.message!;
final containsMessage = _messageQueue.containsMessage(message);
if (!containsMessage) return;
if (message.status == MessageSendingStatus.sent) {
logger?.info('Removing sent message from queue : ${message.id}');
_messageQueue.removeMessage(message);
return;
} else {
if ([
MessageSendingStatus.failed_update,
MessageSendingStatus.failed,
MessageSendingStatus.failed_delete,
].contains(message.status)) {
logger?.info('Adding failed message from event : ${event.type}');
add([message]);
}
}
}).addTo(_compositeSubscription);
}
/// Add a list of messages
void add(List<Message> messages) {
logger?.info('added ${messages.length} messages');
if (messages.isEmpty) return;
if (_messageQueue.containsAllMessage(messages)) return;
logger?.info('Adding ${messages.length} messages');
final messageList = _messageQueue.toList();
_messageQueue.addAll(messages
.where((element) => !messageList.any((m) => m.id == element.id)));
if (_messageQueue.isNotEmpty && !_isRetrying) {
_startRetrying();
}
// we should not add message if already available in the queue
_messageQueue.addAll(messages.where(
(it) => !messageList.any((m) => m.id == it.id),
));
_startRetrying();
}
Future<void> _startRetrying() async {
logger?.info('start retrying');
if (_isRetrying) return;
_isRetrying = true;
final retryPolicy = _retryPolicy!.copyWith(attempt: 0);
logger?.info('Started retrying failed messages');
while (_messageQueue.isNotEmpty) {
logger?.info('${_messageQueue.length} messages remaining in the queue');
final message = _messageQueue.first;
try {
logger?.info('retry attempt ${retryPolicy.attempt}');
await _sendMessage(message);
logger?.info('message sent - removing it from the queue');
_messageQueue.remove(message);
logger?.info('now ${_messageQueue.length} messages in the queue');
retryPolicy.attempt = 0;
} catch (error) {
ApiError? apiError;
if (error is DioError) {
if (error.type == DioErrorType.response) {
_messageQueue.remove(message);
return;
}
apiError = ApiError(
error.response?.data,
error.response?.statusCode,
);
} else if (error is ApiError) {
apiError = error;
if (apiError.status?.toString().startsWith('4') == true) {
_messageQueue.remove(message);
return;
}
}
if (!retryPolicy.shouldRetry(
channel.client,
retryPolicy.attempt,
apiError,
)) {
_messageQueue.toList().forEach(_sendFailedEvent);
_isRetrying = false;
return;
}
retryPolicy.attempt++;
final timeout = retryPolicy.retryTimeout(
channel.client,
retryPolicy.attempt,
apiError,
);
await Future.delayed(timeout);
}
await _runAndRetry(message);
}
_isRetrying = false;
}
void _sendFailedEvent(Message? message) {
final newStatus = message!.status == MessageSendingStatus.sending
? MessageSendingStatus.failed
: (message.status == MessageSendingStatus.updating
? MessageSendingStatus.failed_update
: MessageSendingStatus.failed_delete);
channel.state!.addMessage(message.copyWith(
status: newStatus,
));
Future<void> _runAndRetry(Message message) async {
var attempt = 1;
final maxAttempt = _retryPolicy.maxRetryAttempts;
// early return in case maxAttempt is less than 0
if (attempt > maxAttempt) return;
// ignore: literal_only_boolean_expressions
while (true) {
try {
logger?.info('Message (${message.id}) retry attempt $attempt');
await _retryMessage(message);
logger?.info('Message (${message.id}) sent successfully');
_messageQueue.removeMessage(message);
break;
} on StreamChatError catch (e) {
// retry logic
final maxAttempt = _retryPolicy.maxRetryAttempts;
if (attempt < maxAttempt) {
final shouldRetry = _retryPolicy.shouldRetry(client, attempt, e);
if (shouldRetry) {
final timeout = _retryPolicy.retryTimeout(client, attempt, e);
// temporary failure, continue
logger?.info(
'API call failed (attempt $attempt), '
'retrying in ${timeout.inSeconds} seconds. Error was $e',
);
await Future.delayed(timeout);
attempt += 1;
} else {
logger?.info(
'API call failed (attempt $attempt). '
'Giving up for now, will retry when connection recovers. '
'Error was $e',
);
_sendFailedEvent(message);
break;
}
} else {
logger?.info(
'API call failed (attempt $attempt). '
'Exceeds maxRetryAttempt : $maxAttempt '
'Giving up for now, will retry when connection recovers. '
'Error was $e',
);
_sendFailedEvent(message);
break;
}
} catch (e) {
logger?.info(
'API call failed due to unknown error (attempt $attempt). '
'Giving up for now, will retry when connection recovers. '
'Error was $e',
);
_sendFailedEvent(message);
break;
}
}
}
Future<void> _sendMessage(Message message) async {
void _sendFailedEvent(Message message) {
final newStatus = message.status == MessageSendingStatus.sending
? MessageSendingStatus.failed
: message.status == MessageSendingStatus.updating
? MessageSendingStatus.failed_update
: MessageSendingStatus.failed_delete;
channel.state?.addMessage(message.copyWith(status: newStatus));
}
Future<void> _retryMessage(Message message) async {
if (message.status == MessageSendingStatus.failed_update ||
message.status == MessageSendingStatus.updating) {
await channel.updateMessage(message);
@@ -137,39 +177,10 @@ class RetryQueue {
}
}
void _listenFailedEvents() {
_subscriptions.add(channel.on().listen((event) {
final messageList = _messageQueue.toList();
if (event.message != null) {
final messageIndex =
messageList.indexWhere((m) => m.id == event.message!.id);
if (messageIndex == -1 &&
[
MessageSendingStatus.failed_update,
MessageSendingStatus.failed,
MessageSendingStatus.failed_delete,
].contains(event.message!.status)) {
logger?.info('add message from events');
final m = event.message;
if (m != null) {
add([m]);
}
} else if (messageIndex != -1 &&
[
MessageSendingStatus.sent,
null,
].contains(event.message!.status)) {
_messageQueue.remove(messageList[messageIndex]);
}
}
}));
}
/// Call this method to dispose this object
void dispose() {
_messageQueue.clear();
_subscriptions.forEach((s) => s.cancel());
_compositeSubscription.dispose();
}
static int _byDate(Message m1, Message m2) {
@@ -201,3 +212,27 @@ class RetryQueue {
}
}
}
extension _MessageHeapPriorityQueue on HeapPriorityQueue<Message> {
void removeMessage(Message message) {
final list = toUnorderedList();
final index = list.indexWhere((it) => it.id == message.id);
if (index == -1) return;
final element = list[index];
remove(element);
}
bool containsMessage(Message message) {
final list = toUnorderedList();
final index = list.indexWhere((it) => it.id == message.id);
if (index == -1) return false;
return true;
}
bool containsAllMessage(List<Message> messages) {
if (isEmpty) return false;
final list = toUnorderedList();
final messageIds = messages.map((it) => it.id);
return list.every((it) => messageIds.contains(it.id));
}
}
+15 -19
View File
@@ -7,6 +7,8 @@ import 'package:dio/dio.dart';
import 'package:logging/logging.dart';
import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/src/api/channel.dart';
import 'package:stream_chat/src/core/utils.dart';
import 'package:stream_chat/src/errors/stream_chat_error.dart';
import 'package:stream_chat/src/location.dart';
import 'package:stream_chat/src/ws/connection_status.dart';
import 'package:stream_chat/src/core/api/attachment_file_uploader.dart';
@@ -92,7 +94,7 @@ class StreamChatClient {
tokenManager: _tokenManager,
connectionIdManager: _connectionIdManager,
attachmentFileUploader: attachmentFileUploader,
logger: _detachedLogger('🕸️'),
logger: detachedLogger('🕸️'),
);
_ws = WebSocket(
@@ -100,13 +102,13 @@ class StreamChatClient {
baseUrl: options.baseUrl,
tokenManager: _tokenManager,
handler: handleEvent,
logger: _detachedLogger('🔌'),
logger: detachedLogger('🔌'),
);
_retryPolicy = retryPolicy ??
RetryPolicy(
retryTimeout: (_, int attempt, __) => Duration(seconds: 1 * attempt),
shouldRetry: (_, int attempt, __) => attempt < 5,
shouldRetry: (_, attempt, __) => attempt < 5,
retryTimeout: (_, attempt, __) => Duration(seconds: attempt),
);
state = ClientState(this);
@@ -137,7 +139,7 @@ class StreamChatClient {
/// Whether the chat persistence is available or not
bool get persistenceEnabled => _chatPersistenceClient != null;
RetryPolicy? _retryPolicy;
late final RetryPolicy _retryPolicy;
// sync state of the channels present inside state, defaults to false
bool _synced = false;
@@ -146,7 +148,7 @@ class StreamChatClient {
DateTime? _lastSyncedAt;
/// The retry policy options getter
RetryPolicy? get retryPolicy => _retryPolicy;
RetryPolicy get retryPolicy => _retryPolicy;
/// By default the Chat client will write all messages with level Warn or
/// Error to stdout.
@@ -217,7 +219,8 @@ class StreamChatClient {
if (record.stackTrace != null) print(record.stackTrace);
};
Logger _detachedLogger(
///
Logger detachedLogger(
String name,
) =>
Logger.detached(name)
@@ -303,7 +306,7 @@ class StreamChatClient {
TokenProvider? provider,
}) async {
if (_ws.connectionCompleter?.isCompleted == false) {
throw Exception(
throw const StreamChatError(
'User already getting connected, try calling `disconnectUser` '
'before trying to connect again',
);
@@ -346,11 +349,11 @@ class StreamChatClient {
logger.info('Opening web-socket connection for ${user.id}');
if (wsConnectionStatus == ConnectionStatus.connecting) {
throw Exception('Connection already in progress for ${user.id}');
throw StreamChatError('Connection already in progress for ${user.id}');
}
if (wsConnectionStatus == ConnectionStatus.connected) {
throw Exception('Connection already connected for ${user.id}');
throw StreamChatError('Connection already connected for ${user.id}');
}
_wsConnectionStatus = ConnectionStatus.connecting;
@@ -486,13 +489,6 @@ class StreamChatClient {
}
}
String _generateHash(List<Object?> objects) {
final payload = json.encode(objects);
final payloadBytes = utf8.encode(payload);
final payloadB64 = base64.encode(payloadBytes);
return payloadB64;
}
final _queryChannelsStreams = <String, Future<List<Channel>>>{};
/// Requests channels with a given query.
@@ -512,7 +508,7 @@ class StreamChatClient {
watch = false;
}
final hash = _generateHash([
final hash = generateHash([
filter,
sort,
state,
@@ -575,7 +571,7 @@ class StreamChatClient {
await _ws.connectionCompleter?.future;
}
if (wsConnectionStatus != ConnectionStatus.connected) {
throw Exception(
throw const StreamChatError(
'You cannot use queryChannels without an active connection. '
'Please call `connectUser` to connect the client.',
);
@@ -27,12 +27,12 @@ class AuthInterceptor extends Interceptor {
try {
token = await _tokenManager.loadToken();
} catch (_) {
final error = StreamChatError(ChatErrorCode.undefinedToken);
final error = StreamChatNetworkError(ChatErrorCode.undefinedToken);
final dioError = StreamChatDioError(
error: error,
requestOptions: options,
);
return handler.reject(dioError);
return handler.reject(dioError, true);
}
final params = {'user_id': token.userId};
final headers = {
@@ -17,5 +17,5 @@ class StreamChatDioError extends DioError {
);
@override
final StreamChatError error;
final StreamChatNetworkError error;
}
@@ -100,14 +100,15 @@ class StreamHttpClient {
void close({bool force = false}) => httpClient.close(force: force);
StreamChatNetworkError _parseError(DioError err) {
StreamChatNetworkError error;
// locally thrown dio error
if (err is StreamChatDioError) {
final code = err.error.code;
final message = err.error.message;
return StreamChatNetworkError.raw(code: code, message: message);
error = err.error;
} else {
// real network request dio error
error = StreamChatNetworkError.fromDioError(err);
}
// real network request dio error
return StreamChatNetworkError.fromDioError(err);
return error..stackTrace = err.stackTrace;
}
/// Handy method to make http GET request with error parsing.
@@ -29,7 +29,7 @@ class Event {
this.parentId,
this.extraData = const {},
this.isLocal = true,
}) : createdAt = createdAt ?? DateTime.now();
}) : createdAt = createdAt?.toUtc() ?? DateTime.now().toUtc();
/// Create a new instance from a json
factory Event.fromJson(Map<String, dynamic> json) =>
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:math' as math;
// This alphabet uses `A-Za-z0-9_-` symbols. The genetic algorithm helped
@@ -14,3 +15,11 @@ String randomId({int size = 21}) {
}
return id;
}
/// Creates a hash string from the passed [objects]
String generateHash(List<Object?> objects) {
final payload = json.encode(objects);
final payloadBytes = utf8.encode(payload);
final payloadB64 = base64.encode(payloadBytes);
return payloadB64;
}
@@ -1,91 +1,166 @@
///
enum ChatErrorCode {
// client error codes
networkFailed,
parserError,
socketClosed,
socketFailure,
cantParseConnectionEvent,
cantParseEvent,
invalidToken,
undefinedToken,
unableToParseSocketEvent,
noErrorBody,
// ignore_for_file: lines_longer_than_80_chars
// server error codes
import 'package:collection/collection.dart';
/// Complete list of errors that are returned by the API
/// together with the description and API code.
enum ChatErrorCode {
// Client errors
/// Unauthenticated, token not defined
undefinedToken,
// Bad Request
/// Wrong data/parameter is sent to the API
inputError,
/// Duplicate username is sent while enforce_unique_usernames is enabled
duplicateUsername,
/// Message is too long
messageTooLong,
/// Event is not supported
eventNotSupported,
/// The feature is currently disabled
/// on the dashboard (i.e. Reactions & Replies)
channelFeatureNotSupported,
/// Multiple Levels Reply is not supported
/// the API only supports 1 level deep reply threads
multipleNestling,
/// Custom Command handler returned an error
customCommandEndpointCall,
/// App config does not have custom_action_handler_url
customCommandEndpointMissing,
// Unauthorised
/// Unauthenticated, problem with authentication
authenticationError,
/// Unauthenticated, token expired
tokenExpired,
/// Unauthenticated, token date incorrect
tokenBeforeIssuedAt,
/// Unauthenticated, token not valid yet
tokenNotValid,
tokenDateIncorrect,
tokenSignatureIncorrect,
apiKeyNotFound,
/// Unauthenticated, token signature invalid
tokenSignatureInvalid,
/// Access Key invalid
accessKeyError,
// Forbidden
/// Unauthorised / forbidden to make request
notAllowed,
/// App suspended
appSuspended,
/// User tried to post a message during the cooldown period
cooldownError,
// Miscellaneous
/// Resource not found
doesNotExist,
/// Request timed out
requestTimeout,
/// Payload too big
payloadTooBig,
/// Too many requests in a certain time frame
rateLimitError,
/// Request headers are too large
maximumHeaderSizeExceeded,
/// Something goes wrong in the system
internalSystemError,
/// No access to requested channels
noAccessToChannels
}
const _errorCodeWithDescription = {
ChatErrorCode.undefinedToken:
MapEntry(1000, 'Unauthorised, token not defined'),
ChatErrorCode.inputError:
MapEntry(4, 'Wrong data/parameter is sent to the API'),
ChatErrorCode.duplicateUsername: MapEntry(6,
'Duplicate username is sent while enforce_unique_usernames is enabled'),
ChatErrorCode.messageTooLong: MapEntry(20, 'Message is too long'),
ChatErrorCode.eventNotSupported: MapEntry(18, 'Event is not supported'),
ChatErrorCode.channelFeatureNotSupported: MapEntry(19,
'The feature is currently disabled on the dashboard (i.e. Reactions & Replies)'),
ChatErrorCode.multipleNestling: MapEntry(21,
'Multiple Levels Reply is not supported - the API only supports 1 level deep reply threads'),
ChatErrorCode.customCommandEndpointCall:
MapEntry(45, 'Custom Command handler returned an error'),
ChatErrorCode.customCommandEndpointMissing:
MapEntry(44, 'App config does not have custom_action_handler_url'),
ChatErrorCode.authenticationError:
MapEntry(5, 'Unauthenticated, problem with authentication'),
ChatErrorCode.tokenExpired: MapEntry(40, 'Unauthenticated, token expired'),
ChatErrorCode.tokenBeforeIssuedAt:
MapEntry(42, 'Unauthenticated, token date incorrect'),
ChatErrorCode.tokenNotValid:
MapEntry(41, 'Unauthenticated, token not valid yet'),
ChatErrorCode.tokenSignatureInvalid:
MapEntry(43, 'Unauthenticated, token signature invalid'),
ChatErrorCode.accessKeyError: MapEntry(2, 'Access Key invalid'),
ChatErrorCode.notAllowed:
MapEntry(17, 'Unauthorised / forbidden to make request'),
ChatErrorCode.appSuspended: MapEntry(99, 'App suspended'),
ChatErrorCode.cooldownError:
MapEntry(60, 'User tried to post a message during the cooldown period'),
ChatErrorCode.doesNotExist: MapEntry(16, 'Resource not found'),
ChatErrorCode.requestTimeout: MapEntry(23, 'Request timed out'),
ChatErrorCode.payloadTooBig: MapEntry(22, 'Payload too big'),
ChatErrorCode.rateLimitError:
MapEntry(9, 'Too many requests in a certain time frame'),
ChatErrorCode.maximumHeaderSizeExceeded:
MapEntry(24, 'Request headers are too large'),
ChatErrorCode.internalSystemError:
MapEntry(-1, 'Something goes wrong in the system'),
ChatErrorCode.noAccessToChannels:
MapEntry(70, 'No access to requested channels'),
};
const _authenticationErrors = [
ChatErrorCode.undefinedToken,
ChatErrorCode.authenticationError,
ChatErrorCode.tokenExpired,
ChatErrorCode.tokenBeforeIssuedAt,
ChatErrorCode.tokenNotValid,
ChatErrorCode.tokenSignatureInvalid,
ChatErrorCode.accessKeyError,
ChatErrorCode.noAccessToChannels,
];
///
ChatErrorCode? chatErrorCodeFromCode(int code) => _errorCodeWithDescription.keys
.firstWhereOrNull((key) => _errorCodeWithDescription[key]!.key == code);
///
extension ChatErrorCodeX on ChatErrorCode {
///
String get message => {
// client error message
ChatErrorCode.networkFailed: 'Response is failed. See cause',
ChatErrorCode.parserError: 'Unable to parse error',
ChatErrorCode.socketClosed: 'Server closed connection',
ChatErrorCode.socketFailure: 'See stack trace in logs. '
'Intercept error in error handler of setUser',
ChatErrorCode.cantParseConnectionEvent:
'Unable to parse connection event',
ChatErrorCode.cantParseEvent: 'Unable to parse event',
ChatErrorCode.invalidToken: 'Invalid token',
ChatErrorCode.undefinedToken:
'No defined token. Check if client.setUser was called and finished',
ChatErrorCode.unableToParseSocketEvent:
'Socket event payload either invalid or null',
ChatErrorCode.noErrorBody: 'No error body. See http status code',
// server error message
ChatErrorCode.authenticationError:
'Unauthenticated, problem with authentication',
ChatErrorCode.tokenExpired: 'Token expired, new one must be requested.',
ChatErrorCode.tokenNotValid: 'Unauthenticated, token not valid yet',
ChatErrorCode.tokenDateIncorrect:
'Unauthenticated, token date incorrect',
ChatErrorCode.tokenSignatureIncorrect:
'Unauthenticated, token signature invalid',
ChatErrorCode.apiKeyNotFound:
"Api key is not found, verify it if it's correct or was created.",
}[this]!;
String get message => _errorCodeWithDescription[this]!.value;
///
int get code => {
// client error codes
ChatErrorCode.networkFailed: 1000,
ChatErrorCode.parserError: 1001,
ChatErrorCode.socketClosed: 1002,
ChatErrorCode.socketFailure: 1003,
ChatErrorCode.cantParseConnectionEvent: 1004,
ChatErrorCode.cantParseEvent: 1005,
ChatErrorCode.invalidToken: 1006,
ChatErrorCode.undefinedToken: 1007,
ChatErrorCode.unableToParseSocketEvent: 1008,
ChatErrorCode.noErrorBody: 1009,
// server error codes
ChatErrorCode.authenticationError: 5,
ChatErrorCode.tokenExpired: 40,
ChatErrorCode.tokenNotValid: 41,
ChatErrorCode.tokenDateIncorrect: 42,
ChatErrorCode.tokenSignatureIncorrect: 43,
ChatErrorCode.apiKeyNotFound: 2,
}[this]!;
int get code => _errorCodeWithDescription[this]!.key;
///
Set<int> get authenticationErrors => {
ChatErrorCode.authenticationError,
ChatErrorCode.tokenExpired,
ChatErrorCode.tokenNotValid,
ChatErrorCode.tokenDateIncorrect,
ChatErrorCode.tokenSignatureIncorrect,
}.map((it) => it.code).toSet();
///
bool isAuthenticationError(int code) => authenticationErrors.contains(code);
bool get isAuthenticationError => _authenticationErrors.contains(this);
}
@@ -3,45 +3,74 @@ import 'package:stream_chat/src/errors/chat_error_code.dart';
import 'package:stream_chat/stream_chat.dart';
///
class StreamChatError extends Equatable implements Exception {
class StreamChatError with EquatableMixin implements Exception {
///
StreamChatError(ChatErrorCode errorCode)
: code = errorCode.code,
message = errorCode.message;
///
const StreamChatError.raw(this.code, this.message);
/// Error code
final int code;
const StreamChatError(this.message);
/// Error message
final String message;
@override
List<Object?> get props => [code, message];
List<Object?> get props => [message];
@override
String toString() => 'StreamChatError(code: $code, message: $message)';
String toString() => 'StreamChatError(message: $message)';
}
///
class StreamWebSocketError extends StreamChatError {
///
const StreamWebSocketError(
String message, {
this.data,
}) : super(message);
///
factory StreamWebSocketError.fromStreamError(Map<String, Object?> error) {
final data = ErrorResponse.fromJson(error);
final message = data.message ?? '';
return StreamWebSocketError(message, data: data);
}
///
int? get code => data?.code;
///
ChatErrorCode? get errorCode {
final code = this.code;
if (code == null) return null;
return chatErrorCodeFromCode(code);
}
/// Response body. please refer to [ErrorResponse].
final ErrorResponse? data;
@override
String toString() {
var params = 'message: $message';
if (data != null) params += ', data: $data';
return 'WebSocketError($params)';
}
}
///
class StreamChatNetworkError extends StreamChatError {
///
StreamChatNetworkError({
required ChatErrorCode errorCode,
StreamChatNetworkError(
ChatErrorCode errorCode, {
int? statusCode,
this.data,
}) : statusCode = statusCode ?? data?.statusCode,
super(errorCode);
}) : code = errorCode.code,
statusCode = statusCode ?? data?.statusCode,
super(errorCode.message);
///
const StreamChatNetworkError.raw({
required int code,
StreamChatNetworkError.raw({
required this.code,
required String message,
this.statusCode,
this.data,
}) : super.raw(code, message);
}) : super(message);
///
factory StreamChatNetworkError.fromDioError(DioError error) {
@@ -53,25 +82,46 @@ class StreamChatNetworkError extends StreamChatError {
}
return StreamChatNetworkError.raw(
code: errorResponse?.code ?? -1,
message: errorResponse?.message ?? response?.statusMessage ?? '',
message:
errorResponse?.message ?? response?.statusMessage ?? error.message,
statusCode: errorResponse?.statusCode ?? response?.statusCode,
data: errorResponse,
);
)..stackTrace = error.stackTrace;
}
/// Error code
final int code;
/// HTTP status code
final int? statusCode;
/// Response body. please refer to [ErrorResponse].
final ErrorResponse? data;
@override
List<Object?> get props => [...super.props, statusCode];
StackTrace? _stackTrace;
///
set stackTrace(StackTrace? stack) => _stackTrace = stack;
///
ChatErrorCode? get errorCode => chatErrorCodeFromCode(code);
///
bool get isRetriable => data == null;
@override
String toString() => 'StreamChatNetworkError('
'code: $code, '
'message: $message, '
'statusCode: $statusCode, '
'data: $data)';
List<Object?> get props => [...super.props, code, statusCode];
@override
String toString({bool printStackTrace = false}) {
var params = 'code: $code, message: $message';
if (statusCode != null) params += ', statusCode: $statusCode';
if (data != null) params += ', data: $data';
var msg = 'StreamChatNetworkError($params)';
if (printStackTrace && _stackTrace != null) {
msg += '\n$_stackTrace';
}
return msg;
}
}
@@ -1,53 +0,0 @@
import 'dart:convert';
/// Exception related to api calls
class ApiError extends Error {
/// Creates a new ApiError instance using the response body and status code
ApiError(this.body, this.status) : jsonData = _decode(body) {
if (jsonData != null && jsonData!.containsKey('code')) {
_code = jsonData!['code'];
}
}
/// Raw body of the response
final String? body;
/// Json parsed body
final Map<String, dynamic>? jsonData;
/// Http status code of the response
final int? status;
/// Stream specific error code
int? get code => _code;
int? _code;
static Map<String, dynamic>? _decode(String? body) {
try {
if (body == null) {
return null;
}
return json.decode(body);
} on FormatException {
return null;
}
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ApiError &&
runtimeType == other.runtimeType &&
body == other.body &&
jsonData == other.jsonData &&
status == other.status &&
_code == other._code;
@override
int get hashCode =>
body.hashCode ^ jsonData.hashCode ^ status.hashCode ^ _code.hashCode;
@override
String toString() => 'ApiError{body: $body, jsonData: $jsonData, '
'status: $status, code: $_code}';
}
@@ -1,25 +0,0 @@
///
class SocketError {
///
const SocketError({
this.code = -1,
this.statusCode = -1,
this.message = '',
});
///
factory SocketError.fromJson(Map<String, dynamic> json) => SocketError(
code: json['code'],
statusCode: json['StatusCode'],
message: json['message'],
);
///
final int code;
///
final int statusCode;
///
final String message;
}
+36 -19
View File
@@ -5,13 +5,14 @@ import 'dart:math' as math;
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/src/errors/chat_error_code.dart';
import 'package:stream_chat/src/errors/stream_chat_error.dart';
import 'package:stream_chat/src/ws/connection_status.dart';
import 'package:stream_chat/src/core/http/token.dart';
import 'package:stream_chat/src/core/http/token_manager.dart';
import 'package:stream_chat/src/core/models/event.dart';
import 'package:stream_chat/src/core/models/user.dart';
import 'package:stream_chat/src/event_type.dart';
import 'package:stream_chat/src/ws/socket_error.dart';
import 'package:stream_chat/src/ws/timer_helper.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/status.dart' as status;
@@ -27,8 +28,6 @@ typedef WebSocketChannelProvider = WebSocketChannel Function(
Iterable<String>? protocols,
});
const _tokenExpiredErrorCode = 40;
/// A WebSocket connection that reconnects upon failure.
class WebSocket with TimerHelper {
/// Creates a new websocket
@@ -174,7 +173,7 @@ class WebSocket with TimerHelper {
/// Connect the WS using the parameters passed in the constructor
Future<Event> connect(User user) async {
if (_connectRequestInProgress) {
throw Exception('''
throw const StreamWebSocketError('''
You've called connect twice,
can only attempt 1 connection at the time,
''');
@@ -195,7 +194,7 @@ class WebSocket with TimerHelper {
int _reconnectAttempt = 0;
bool _reconnectRequestInProgress = false;
Future<void> _reconnect({bool refreshToken = false}) async {
void _reconnect({bool refreshToken = false}) async {
_logger?.info('Retrying connection : $_reconnectAttempt');
if (_reconnectRequestInProgress) return;
_reconnectRequestInProgress = true;
@@ -270,10 +269,35 @@ class WebSocket with TimerHelper {
_connectionStatus = ConnectionStatus.connected;
}
void _handleStreamError(Map<String, Object?> errorResponse) {
// resetting connect, reconnect request flag
_resetRequestFlags();
final error = StreamWebSocketError.fromStreamError(errorResponse);
final isTokenExpired = error.errorCode == ChatErrorCode.tokenExpired;
if (isTokenExpired && !tokenManager.isStatic) {
_logger?.warning('Connection failed, token expired');
return _reconnect(refreshToken: true);
}
_logger?.severe('Connection failed', error);
final completer = connectionCompleter;
// complete with error if not yet completed
if (completer != null && !completer.isCompleted) {
// complete the connection with error
completer.completeError(error);
// disconnect the web-socket connection
return disconnect();
}
return _reconnect();
}
void _onDataReceived(dynamic data) {
final jsonData = json.decode(data);
final error = jsonData['error'];
if (error != null) return _onConnectionError(error);
final jsonData = json.decode(data) as Map<String, Object?>;
final error = jsonData['error'] as Map<String, Object?>?;
if (error != null) return _handleStreamError(error);
// resetting connect, reconnect request flag
_resetRequestFlags(resetAttempts: true);
@@ -300,25 +324,18 @@ class WebSocket with TimerHelper {
}
void _onConnectionError(error, [stacktrace]) {
_logger?.severe('Error occurred', error, stacktrace);
_logger?.warning('Error occurred', error, stacktrace);
// resetting connect, reconnect request flag
_resetRequestFlags();
var refreshToken = false;
try {
final socketError = SocketError.fromJson(json.decode(error));
refreshToken = socketError.code == _tokenExpiredErrorCode;
} catch (_) {}
// refresh token in case it is expired
_reconnect(refreshToken: refreshToken);
_reconnect();
}
bool _manuallyClosed = false;
void _onConnectionClosed() {
_logger?.info('Connection closed : $connectionId');
_logger?.warning('Connection closed : $connectionId');
// resetting connect, reconnect request flag
_resetRequestFlags();
@@ -381,7 +398,7 @@ class WebSocket with TimerHelper {
if (connectionStatus == ConnectionStatus.disconnected) return;
_connectionStatus = ConnectionStatus.disconnected;
_logger?.info('Disconnecting $connectionId');
_logger?.info('Disconnecting web-socket connection');
// resetting user
_user = null;
+2 -1
View File
@@ -32,8 +32,9 @@ export './src/core/models/reaction.dart';
export './src/core/models/read.dart';
export './src/core/models/user.dart';
export './src/db/chat_persistence_client.dart';
export './src/errors/chat_error_code.dart';
export './src/errors/stream_chat_error.dart';
export './src/event_type.dart';
export './src/exceptions.dart';
export './src/extensions/rate_limit.dart';
export './src/extensions/string_extension.dart';
export './src/location.dart';
@@ -8,7 +8,6 @@ import 'package:logging/logging.dart';
import 'package:mocktail/mocktail.dart';
import 'package:stream_chat/src/core/api/requests.dart';
import 'package:stream_chat/src/client.dart';
import 'package:stream_chat/src/exceptions.dart';
import 'package:stream_chat/src/core/models/channel_model.dart';
import 'package:stream_chat/src/core/models/filter.dart';
import 'package:stream_chat/src/core/models/message.dart';
@@ -341,7 +341,8 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
okText: 'OK',
);
} catch (err) {
if (err is ApiError && json.decode(err.body ?? '{}')['code'] == 4) {
if (err is StreamChatNetworkError &&
err.errorCode == ChatErrorCode.inputError) {
await showInfoDialog(
context,
icon: StreamSvgIcon.flag(
@@ -51,7 +51,7 @@ class MoorChatDatabase extends _$MoorChatDatabase {
// you should bump this number whenever you change or add a table definition.
@override
int get schemaVersion => 3;
int get schemaVersion => 4;
@override
MigrationStrategy get migration => MigrationStrategy(