Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5fd710f
test(e2e): add e2e_app, port native e2e suites, one app process per C…
testableapple Oct 23, 2026
0b8aa6d
chore(repo): ignore e2e mock server, Allure results and allurectl in …
testableapple Oct 23, 2026
b4979e4
test(repo): link every e2e test to its Allure TestOps case
testableapple Oct 23, 2026
b71afe2
Merge remote-tracking branch 'origin/master' into test/e2e-native-parity
testableapple Oct 23, 2026
b03e78e
test(repo): unmount the e2e app before resetting the client, verify c…
testableapple Oct 23, 2026
3bb8bd3
ci(repo): run PR e2e on API 36, use a preinstalled NDK, retry failed …
testableapple Oct 23, 2026
e7510b3
test(repo): retype into a field that ignored the text
testableapple Oct 23, 2026
1e75428
test(repo): keep the copied message id in the e2e app, bound the vid…
testableapple Oct 23, 2026
4b7a5fa
Merge branch 'master' into test/e2e-native-parity
testableapple Oct 23, 2026
8d9cef2
test(repo): scroll until a message is on screen, stop when the emulat…
testableapple Oct 23, 2026
0e4da80
ci(repo): give the e2e app's Gradle the sample app's memory settings
testableapple Oct 23, 2026
4a66a48
Bump timeout
testableapple Oct 24, 2026
ab90f31
test(repo): restart a dead emulator during the e2e run
testableapple Oct 24, 2026
c883599
test(repo): time-limit e2e runs so a dead emulator can't hang the job
testableapple Oct 24, 2026
87586b9
fix(repo): look up timeout through sh so the lane runs on Linux
testableapple Oct 24, 2026
d42a93a
ci(repo): split the e2e coverage nightly into 4 batches
testableapple Oct 25, 2026
8e73290
ci(repo): give the e2e runners memory headroom
testableapple Oct 25, 2026
b6acb2a
test(repo): retry the tap that opens the image gallery
testableapple Oct 25, 2026
38995d2
fix(llc): don't stall when the token provider fails during a refresh
xsahil03x Oct 26, 2026
254f1f6
Merge remote-tracking branch 'origin/test/e2e-native-parity' into sah…
xsahil03x Oct 26, 2026
87bcdc4
Merge remote-tracking branch 'origin/master' into sahil/flu-1084-conn…
xsahil03x Oct 27, 2026
6b4f2ff
Merge remote-tracking branch 'origin/master' into sahil/flu-1084-conn…
xsahil03x Oct 28, 2026
cc64368
fix(llc): refresh the token again after a failed refresh, stop after …
xsahil03x Oct 28, 2026
1ede391
fix(llc): don't open a socket when the token loads after disconnect
xsahil03x Oct 28, 2026
7ec55e2
Merge remote-tracking branch 'origin/master' into sahil/flu-1084-conn…
xsahil03x Oct 28, 2026
6be2012
Merge remote-tracking branch 'origin/master' into sahil/flu-1084-conn…
xsahil03x Oct 28, 2026
b224a95
fix(llc): keep a token set while a failed refresh was pending
xsahil03x Oct 28, 2026
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
1 change: 1 addition & 0 deletions packages/stream_chat/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

🐞 Fixed

- Fixed the connection getting stuck while connecting, and requests never completing, when the token provider throws while refreshing an expired token.
- Fixed sending, updating and deleting a message not retrying when the server answers with a timeout (408), a rate limit (429) or a server error (5xx).
- Fixed hard-deleting an unread message not lowering the channel's unread count.
- Fixed a draft saved or deleted while offline not being kept.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,13 @@ class AuthInterceptor extends QueuedInterceptor {
final error = ErrorResponse.fromJson(data);
if (error.code == ChatErrorCode.tokenExpired.code) {
if (_tokenManager.isStatic) return handler.next(exception);
await _tokenManager.loadToken(refresh: true);
try {
await _tokenManager.loadToken(refresh: true);
} catch (_) {
// Without a new token the request can't be retried, so it fails with
// the original error.
return handler.next(exception);
}
try {
final options = exception.requestOptions;
final response = await _client.fetch(options);
Expand Down
13 changes: 11 additions & 2 deletions packages/stream_chat/lib/src/core/http/token_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,17 @@ class TokenManager {
'Please call `setTokenOrProvider` before calling `loadToken`',
);
if (refresh || _token == null) {
final rawValue = await _tokenProvider!(_userId!);
_token = Token.fromRawValue(rawValue);
final expiredToken = _token;
try {
final rawValue = await _tokenProvider!(_userId!);
_token = Token.fromRawValue(rawValue);
} catch (_) {
// The token being refreshed has expired, so the next load fetches a
// new one instead of returning it. A token set while the refresh was
// pending is kept.
if (refresh && identical(_token, expiredToken)) _token = null;
rethrow;
}
}
return _token!;
}
Expand Down
13 changes: 8 additions & 5 deletions packages/stream_chat/lib/src/ws/websocket.dart
Original file line number Diff line number Diff line change
Expand Up @@ -322,14 +322,17 @@ class WebSocket with TimerHelper {
// Reconnect may have been paused while the delay was pending.
if (!_reconnectEnabled) return;

final uri = await _buildUri(
refreshToken: refreshToken,
includeUserDetails: false,
);
_logger?.info('[reconnect] #ws; uri: $uri');
try {
final uri = await _buildUri(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
xsahil03x marked this conversation as resolved.
Resolved
refreshToken: refreshToken,
includeUserDetails: false,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// Disconnected while the token was loading.
if (_user == null) return;
_logger?.info('[reconnect] #ws; uri: $uri');
_initWebSocketChannel(uri);
} catch (e, stk) {
if (_user == null) return;
_onConnectionError(e, stk);
Comment thread
xsahil03x marked this conversation as resolved.
Resolved
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,42 @@ void main() {
},
);

test('`onError` rejects with the original error when refreshing the token fails', () async {
const path = 'test-request-path';
final options = RequestOptions(path: path);
const code = ChatErrorCode.tokenExpired;
final errorResponse = ErrorResponse()
..code = code.code
..message = code.message;
final response = Response(
requestOptions: options,
data: errorResponse.toJson(),
);
final err = DioException(requestOptions: options, response: response);
final handler = ErrorInterceptorHandler();

when(() => tokenManager.isStatic).thenReturn(false);
when(() => tokenManager.loadToken(refresh: true)).thenThrow(Exception('token provider is down'));

authInterceptor.onError(err, handler);

Object? caught;
try {
await handler.future.timeout(const Duration(seconds: 5));
} catch (e) {
caught = e;
}

// need to cast it as the type is private in dio
final error = (caught as dynamic)?.data;
expect(error, isA<DioException>());
expect(
StreamChatNetworkError.fromDioException(error as DioException).errorCode,
code,
);
verifyNever(() => client.fetch(options));
});

test(
'`onError` should reject with error if error is not a `tokenExpired error`',
() async {
Expand Down
46 changes: 46 additions & 0 deletions packages/stream_chat/test/src/core/http/token_manager_test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:stream_chat/src/core/http/token.dart';
import 'package:stream_chat/src/core/http/token_manager.dart';
import 'package:test/test.dart';
Expand Down Expand Up @@ -125,6 +127,50 @@ void main() {
},
);

test('`.loadToken` fetches a new token after a refresh fails', () async {
const userId = 'test-user-id';
final expiredToken = Token.development(userId);
final newToken = Token.fromRawValue('${expiredToken.rawValue}-new');

var providerCalls = 0;
Future<String> tokenProvider(String userId) async {
providerCalls += 1;
return switch (providerCalls) {
1 => expiredToken.rawValue,
2 => throw Exception('token provider is down'),
_ => newToken.rawValue,
};
}

await tokenManager.setTokenOrProvider(userId, provider: tokenProvider);

await expectLater(tokenManager.loadToken(refresh: true), throwsException);

expect(await tokenManager.loadToken(), newToken);
});

test('`.loadToken` keeps a token set while a failed refresh was pending', () async {
final refresh = Completer<String>();
var providerCalls = 0;
Future<String> tokenProvider(String userId) async {
providerCalls += 1;
if (providerCalls == 2) return refresh.future;
return Token.development(userId).rawValue;
}

await tokenManager.setTokenOrProvider('user-a', provider: tokenProvider);
final pendingRefresh = tokenManager.loadToken(refresh: true);

tokenManager.reset();
final userBToken = Token.development('user-b');
await tokenManager.setTokenOrProvider('user-b', token: userBToken);

refresh.completeError(Exception('token provider is down'));
await expectLater(pendingRefresh, throwsException);

expect(await tokenManager.loadToken(), userBToken);
});

test('`.reset` should reset the tokenManager', () async {
const userId = 'test-user-id';
final token = Token.development(userId);
Expand Down
113 changes: 113 additions & 0 deletions packages/stream_chat/test/src/ws/websocket_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';

import 'package:mocktail/mocktail.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/ws/websocket.dart';
import 'package:stream_chat/stream_chat.dart';
Expand Down Expand Up @@ -507,6 +508,118 @@ void main() {
},
);

test('a token provider that fails during a refresh does not stall reconnecting', () async {
final user = OwnUser(id: 'test-user');

var providerCalls = 0;
final providerTokenManager = TokenManager();
await providerTokenManager.setTokenOrProvider(
user.id,
provider: (userId) async {
providerCalls += 1;
// The refresh after the token expires fails.
if (providerCalls == 2) throw Exception('token provider is down');
return Token.development(userId).rawValue;
},
);

var channelsOpened = 0;
final providerWebSocket = WebSocket(
apiKey: 'api-key',
baseUrl: 'ws://<local-ip>:8800',
tokenManager: providerTokenManager,
webSocketChannelProvider: (_, {protocols}) {
channelsOpened += 1;
return webSocketChannel;
},
);
addTearDown(providerWebSocket.disconnect);

final timer = Timer(const Duration(milliseconds: 300), () {
final event = Event(type: EventType.healthCheck, connectionId: 'connection-id', me: user);
webSocketSink.add(json.encode(event));
});
addTearDown(timer.cancel);

await providerWebSocket.connect(user);
expect(channelsOpened, 1);

final tokenExpired = ErrorResponse()
..code = ChatErrorCode.tokenExpired.code
..message = 'Token expired';
webSocketSink.add(json.encode({'error': tokenExpired}));

// The failed refresh still leads to another connection attempt, which
// fetches a new token instead of reusing the expired one.
await Future.doWhile(() async {
await Future<void>.delayed(const Duration(milliseconds: 100));
return channelsOpened < 2;
}).timeout(const Duration(seconds: 15));

expect(providerCalls, 3);
});

for (final refreshFails in [true, false]) {
final outcome = refreshFails ? 'fails' : 'succeeds';
test('a token refresh that $outcome after `disconnect` does not reconnect', () async {
final user = OwnUser(id: 'test-user');

final refresh = Completer<String>();
var providerCalls = 0;
var channelsOpened = 0;
final providerTokenManager = TokenManager();
await providerTokenManager.setTokenOrProvider(
user.id,
provider: (userId) async {
providerCalls += 1;
if (providerCalls == 2) return refresh.future;
return Token.development(userId).rawValue;
},
);

final providerWebSocket = WebSocket(
apiKey: 'api-key',
baseUrl: 'ws://<local-ip>:8800',
tokenManager: providerTokenManager,
webSocketChannelProvider: (_, {protocols}) {
channelsOpened += 1;
return webSocketChannel;
},
);
addTearDown(providerWebSocket.disconnect);

final timer = Timer(const Duration(milliseconds: 300), () {
final event = Event(type: EventType.healthCheck, connectionId: 'connection-id', me: user);
webSocketSink.add(json.encode(event));
});
addTearDown(timer.cancel);

await providerWebSocket.connect(user);

final tokenExpired = ErrorResponse()
..code = ChatErrorCode.tokenExpired.code
..message = 'Token expired';
webSocketSink.add(json.encode({'error': tokenExpired}));

// Wait for the reconnect to start refreshing the token.
await Future.doWhile(() async {
await Future<void>.delayed(const Duration(milliseconds: 100));
return providerCalls < 2;
}).timeout(const Duration(seconds: 15));

providerWebSocket.disconnect();
if (refreshFails) {
refresh.completeError(Exception('token provider is down'));
} else {
refresh.complete(Token.development(user.id).rawValue);
}
await Future<void>.delayed(const Duration(milliseconds: 100));

expect(providerWebSocket.connectionStatus, ConnectionStatus.disconnected);
expect(channelsOpened, 1);
});
}

test('`disconnect` successfully disconnects the current user', () async {
final user = OwnUser(id: 'test-user');
const connectionId = 'test-connection-id';
Expand Down
Loading