Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e6ac4e4
feat(llc)!: adopt the stream_core error layer and upload task API
xsahil03x Aug 31, 2026
745f248
chore(deps): re-pin stream_core to the reviewed upload API
xsahil03x Aug 31, 2026
d4c870e
chore(deps): re-pin stream_core to the batch constructor cleanup
xsahil03x Aug 31, 2026
a02c45e
chore(deps): re-pin stream_core, and import dart:typed_data directly
xsahil03x Aug 31, 2026
39c109b
docs(llc): update the upload snippet to the task API
xsahil03x Aug 31, 2026
e5f858e
fix(llc): act on review of the error layer migration
xsahil03x Aug 31, 2026
0619681
docs(llc): say that colliding attachment ids throw
xsahil03x Aug 31, 2026
13a4e25
docs(llc): shorten the StreamFeedsException doc back down
xsahil03x Aug 31, 2026
3182f08
docs(llc): stop the upload snippet contradicting itself
xsahil03x Aug 31, 2026
c4414b9
chore(deps): re-pin stream_core to the doc cleanup
xsahil03x Aug 31, 2026
20d6b6f
docs(changelog): file the connect throw under breaking, not changed
xsahil03x Aug 31, 2026
e382757
chore(deps): re-pin stream_core, dropping the typed_data import again
xsahil03x Aug 31, 2026
309fe20
chore(deps): re-pin stream_core to the restored typed_data export
xsahil03x Aug 31, 2026
b79c29a
fix(llc): retry a capabilities fetch that failed on the network
xsahil03x Aug 31, 2026
240d425
chore(deps): re-pin stream_core to the reconnection doc fixes
xsahil03x Aug 31, 2026
3f787f9
docs(llc): say what the error layer removed, and what else it broke
xsahil03x Sep 1, 2026
60f0caf
fix(llc): keep the stack trace that points at the failure
xsahil03x Sep 1, 2026
0e44773
docs(test): name the exceptions connect actually throws
xsahil03x Sep 1, 2026
d77277c
test(llc): ask for the capabilities request the handler actually sends
xsahil03x Sep 1, 2026
89b5f9a
test(llc): pin the duplicate attachment id where callers meet it
xsahil03x Sep 1, 2026
4f9821f
refactor(llc): read the time through package:clock
xsahil03x Sep 1, 2026
26e0388
fix(llc): let a batcher run more than one batch
xsahil03x Sep 1, 2026
c526e98
fix(feeds): follow stream_core dropping stackTrace from its exceptions
xsahil03x Sep 1, 2026
35a9027
fix(feeds): point melos at the core ref and clock version the package…
xsahil03x Sep 1, 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
28 changes: 17 additions & 11 deletions docs/code_snippets/03_03_file_uploads.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,23 @@ Future<void> howToUploadAFileOrImageStep1() async {
custom: {'width': 600, 'height': 400},
);

// Upload the attachment
final result = await attachmentUploader.upload(
streamAttachment,
// Optionally track upload progress
onProgress: (progress) {
// Handle progress updates
},
);

// Map the result to an Attachment model to send with an activity
final uploadedAttachment = result.getOrThrow();
// Start the upload. The task comes back straight away, already running, and
// `task.cancel()` calls it off.
final task = attachmentUploader.upload(streamAttachment);

// Optionally track upload progress. `fraction` is null while the file's
// length is unknown, which is when an indeterminate bar is the right thing
// to show.
task.state.listen((state) {
if (state case UploadInProgress(progress: UploadProgress(:final fraction?))) {
print('${(fraction * 100).round()}% sent');
}
});

// Map the result to an Attachment model to send with an activity. A failed
// upload carries its error, so `getOrThrow` opts into throwing it; `fold`
// handles it instead.
final uploadedAttachment = (await task.result).getOrThrow();
final attachmentReadyToBeSent = Attachment(
imageUrl: uploadedAttachment.remoteUrl,
assetUrl: uploadedAttachment.remoteUrl,
Expand Down
4 changes: 3 additions & 1 deletion melos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ command:
auto_route: ^11.0.0
collection: ^1.18.0
chewie: ^1.11.3
clock: ^1.1.2
dio: ^5.9.0
equatable: ^2.0.5
flutter_state_notifier: ^1.0.0
Expand Down Expand Up @@ -53,7 +54,7 @@ command:
stream_core:
git:
url: https://github.com/GetStream/stream-core-flutter.git
ref: f83b5d4d706a79fc429de2d27aead4394b83c1fb
ref: 40b1180f6b5f9ca53a5ecb2c74152fb1c6b4a376
path: packages/stream_core
video_player: ^2.10.0
uuid: ^4.5.1
Expand All @@ -63,6 +64,7 @@ command:
dev_dependencies:
auto_route_generator: ^10.0.0
build_runner: ^2.4.15
fake_async: ^1.3.3
flutter_launcher_icons: ^0.14.4
freezed: ^3.0.0
injectable_generator: ^3.0.0
Expand Down
7 changes: 6 additions & 1 deletion packages/stream_feeds/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
- `PollResponseData.votingVisibility` is now required, so anything constructing one directly must supply it
- `ActivityCommentList.state` returns `ActivityCommentListState` rather than `StateNotifier<ActivityCommentListState>`, matching the other state classes
- Removed the call, recording, streaming and chat types that were never part of the Feeds API
- Every failure the SDK reports for work it attempted now arrives as a `StreamException` subclass — `StreamApiException`, `StreamNetworkException`, `StreamAuthenticationException` or `StreamClientException` — replacing `ClientException` and `HttpClientException`, which are removed. `StreamApiError` remains, as the server's error payload and the type of `ConnectionErrorEvent.error`, but is no longer what the SDK throws or returns. `StreamFeedsException` aliases the base type, so one `on` clause catches all four
- `connect` throws a `StateError` when a connection is already established or in progress, and a `StreamFeedsException` carrying the cause when it fails
- `StreamAttachmentUploader.upload`, reached through `StreamFeedsClient.attachmentUploader`, returns an `AttachmentUploadTask` rather than a `Future<Result<UploadedAttachment>>`, and takes no `onProgress`: progress arrives on the task's `state`. `uploadBatch` returns an `AttachmentUploadBatch` rather than a `Stream<Result<UploadedAttachment>>`
- `Feed.addActivity`, `Feed.addComment` and `Activity.addCommentsBatch` throw an `ArgumentError` when two attachments in one request share an id, rather than reporting it through the returned `Result`

### ✨ Features

Expand All @@ -29,15 +33,16 @@

### 🐛 Bug Fixes

- Fixed a batch never running again after its first: an add that arrived once a batch had run joined that settled one instead of starting its own, so feed capabilities were fetched once per client and every feed discovered afterwards was answered with the first batch's result
- Fixed `connect` failing when called straight after `disconnect`
- Fixed a connection that could not authenticate hanging until it timed out, rather than failing with the reason
- Fixed the `X-Stream-Client` header: the SDK identifier was sent twice, the version was hardcoded, and the OS was left out

### 🔄 Changed

- Attachment uploads for a batch of requests now share one concurrency limit instead of one each, so `Activity.addCommentsBatch` no longer starts several uploads per comment at once; a failure also calls off the uploads still in flight rather than letting them finish work that is about to be discarded
- `disconnect` now only closes the connection, leaving the client reusable with its existing subscriptions intact; releasing it is `dispose`
- An expired token now recovers on its own: the connection comes back with one the `TokenProvider` issued afterwards, without the app doing anything
- `connect` throws a `ClientException` when a connection is already established or in progress, and the one it throws on failure carries the underlying cause
- Renamed the types below. The old names still compile, with a deprecation warning, and `dart fix --apply` migrates them:

| Old name | New name |
Expand Down
2 changes: 1 addition & 1 deletion packages/stream_feeds/lib/src/cdn/cdn_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,5 @@ abstract interface class CdnApi {

class _ResultCallAdapter<T> extends CallAdapter<Future<T>, Future<Result<T>>> {
@override
Future<Result<T>> adapt(Future<T> Function() call) => runSafely(call);
Future<Result<T>> adapt(Future<T> Function() call) => runApiSafely(call);
}
40 changes: 27 additions & 13 deletions packages/stream_feeds/lib/src/client/feeds_client_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -255,15 +255,18 @@ class StreamFeedsClientImpl with Disposable implements StreamFeedsClient {

Future<void> _authenticateUser(
WsRequestSender send,
StreamApiError? previousError,
StreamApiException? previousError,
) async {
if (previousError?.isTokenExpiredError ?? false) {
if (previousError?.isTokenExpired ?? false) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since the line is being touched anyway: previousError?.isTokenExpired == true reads better than ?? false for a nullable bool, and it's what the rest of the codebase leans towards.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Left as ?? false for now — you and CodeRabbit both landed on this line, and it is the one place I would rather not touch twice in one review. Happy to take it in a follow-up sweep if the codebase is standardising on == true.

_tokenManager.expireToken();

// A guest cannot refresh: another exchange answers with a different guest. The session ends
// here, and the app starts another by building a new client.
if (_tokenManager.usesStaticProvider) {
throw ClientException(message: 'The token was refused and the provider has no other to give');
throw StreamAuthenticationException(
message: 'The token was refused and the provider has no other to give',
cause: previousError,
);
}
}

Expand Down Expand Up @@ -297,11 +300,11 @@ class StreamFeedsClientImpl with Disposable implements StreamFeedsClient {
}

if (connectionState.value case Connecting() || Authenticating()) {
throw ClientException(message: 'Connection already in progress for ${user.id}');
throw StateError('Connection already in progress for ${user.id}');
}

if (connectionState.value case Connected()) {
throw ClientException(message: 'Connection already available for ${user.id}');
throw StateError('Connection already available for ${user.id}');
}

_logger.d(() => 'connect ${user.id} (${user.type.name}), webSocket: $connectWebSocket');
Expand Down Expand Up @@ -332,14 +335,16 @@ class StreamFeedsClientImpl with Disposable implements StreamFeedsClient {
Future<void> _exchangeForGuestIdentity() async {
final result = await _guestRepository.createGuest(user);

// Reported like every other connect failure, with the cause attached.
final response = result.getOrElse(
(error, stackTrace) => throw ClientException(
// Reported like every other connect failure, already classified by the API call seam.
final response = result.getOrElse((error, stackTrace) {
var exception = StreamException.tryFrom(error);
exception ??= StreamClientException(
message: 'Failed to create a guest user',
error: error,
stackTrace: stackTrace,
),
);
cause: error,
);

Error.throwWithStackTrace(exception, stackTrace ?? StackTrace.current);
});

final tokenProvider = TokenProvider.static(response.token);
_logger.d(() => 'guest created, server assigned ${response.user.id}');
Expand Down Expand Up @@ -370,7 +375,16 @@ class StreamFeedsClientImpl with Disposable implements StreamFeedsClient {

if (state case Disconnected(:final source)) {
_logger.w(() => 'connect ${user.id} failed: ${source.closeReason}', error: source.cause);
throw ClientException(message: source.closeReason, error: source.cause);

var exception = StreamException.tryFrom(source.cause);
exception ??= StreamNetworkException(message: source.closeReason, cause: source.cause);

final stackTrace = switch (source) {
ServerInitiated(:final stackTrace) || AuthenticationFailed(:final stackTrace) => stackTrace,
UserInitiated() || SystemInitiated() || UnHealthyConnection() || ConnectTimeout() => null,
};

Error.throwWithStackTrace(exception, stackTrace ?? StackTrace.current);
}

_logger.d(() => 'connected ${user.id}');
Expand Down
11 changes: 9 additions & 2 deletions packages/stream_feeds/lib/src/feeds_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ import 'state/user_list.dart';

export 'client/moderation_client.dart';

/// The root of the failures this SDK reports.
///
/// An alias of [StreamException], so code written against either name catches
/// the same failures.
typedef StreamFeedsException = StreamException;

/// {@template stream_feeds_client}
/// Stream Feeds client for building scalable newsfeeds and activity streams.
///
Expand Down Expand Up @@ -258,8 +264,9 @@ abstract interface class StreamFeedsClient {

/// Establishes a connection to the Stream service.
///
/// Call this before anything else on the client. Throws a [ClientException] if the connection
/// fails, or if one is already established or in progress, and a [StateError] after [dispose].
/// Call this before anything else on the client. Throws a [StreamFeedsException] if the
/// connection fails, and a [StateError] when one is already established or in progress, or
/// after [dispose].
///
/// Pass [connectWebSocket] as `false` if the client only needs to make requests. In that case:
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -801,5 +801,5 @@ abstract interface class DefaultApi {

class _ResultCallAdapter<T> extends CallAdapter<Future<T>, Future<Result<T>>> {
@override
Future<Result<T>> adapt(Future<T> Function() call) => runSafely(call);
Future<Result<T>> adapt(Future<T> Function() call) => runApiSafely(call);
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,25 +77,15 @@ class CapabilitiesRepository {
}

extension on Result<Map<String, List<FeedOwnCapability>>> {
bool shouldRetry() {
switch (this) {
case api.Success():
return false;

case final api.Failure failure:
final error = failure.error;
if (error is! StreamDioException) {
return false;
}
final exception = error.exception;
if (exception is! HttpClientException) {
return false;
}
final statusCode = exception.statusCode;
if (statusCode == null) {
return false;
}
return statusCode < 100 || statusCode >= 500;
}
}
bool shouldRetry() => switch (this) {
api.Success() => false,
api.Failure(:final error) => switch (error) {
StreamNetworkException(isCancelled: true) => false,
StreamNetworkException() => true,
// A rate limit is not retried here: this waits a fixed moment, which is
// not the wait a rate limit asks for.
StreamApiException(:final statusCode) => statusCode < 100 || statusCode >= 500,
_ => false,
Comment on lines +83 to +88

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The rewrite added two decisions that weren't here before — a cancelled network error doesn't retry, and neither does a 4xx — and only the "network error does retry" case got a test. Both are cheap to pin down, and the cancelled one especially: if that branch ever regressed, the retry would fire against a request that was deliberately called off during dispose, and nothing would notice.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Tried to pin both and could not, so here is what I found rather than a promise.

Proving a negative here means showing a second call never happens, and the call it must not make is scheduled behind two nested waits: CapabilitiesRepository's 500 ms backoff, and before that Batcher's 2-second collection window. Asserting immediately passes on broken code; waiting it out costs ~2.5 s per test.

So I spiked time control. package:clock cannot do it — it is DateTime-only, no timers. fake_async needs everything in one zone, so I added a useFakeAsync flag to testWithTester that builds the client, connects and runs setUp inside it. That part worked — the client reached Connected and the event was handled under fake time. But ownBatch still never fired even after elapsing past both windows, and chasing it further meant going deeper into Batcher than this finding justifies. I reverted the spike.

One real bug fell out: Batcher._planBatchFetch uses DateTime.now() rather than clock.now(), so its window reads real wall time under any faked clock. Migrating that is going in separately.

The gap stands, and the follow-up now has a concrete scope: make the batcher and the backoff observable under fake time, then both branches become one-line assertions.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correcting my earlier reply on this thread — I was wrong twice.

fakeAsync does work here. My first attempt failed because I built the client outside the zone. Stream.listen binds the zone it is called in, so a client subscribed outside schedules its work outside, where elapse cannot reach it. Building it inside makes all eight branches assertable with no sleeping — I had them green.

And the reason nothing was retrying was a bug, not the test. Batcher.add does _nextActionCompleter ??= _planBatchFetch(), but _planBatchFetch owns that field and clears it when it runs the batch on the spot, so the ??= wrote a completed completer straight back. After the first batch every later add joined that settled one instead of planning its own. Feed capabilities were being fetched once per client — every feed discovered afterwards got the first batch's result.

Fixed in 26e0388 with six tests, and 4f9821f moves Batcher and FeedState onto package:clock so the window is answerable to a test at all.

The eight retry-branch tests are not in this PR. Getting them to the state surface needs a buildFeedTester — a synchronous builder beside feedTest, the same shape core has with buildTester beside wsClientTest — and that is harness work that deserves its own review rather than riding along here. Following up separately; the branches themselves are written and passing, so it is a matter of landing the harness.

},
};
}
20 changes: 20 additions & 0 deletions packages/stream_feeds/lib/src/state/activity.dart
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,16 @@ class Activity with Disposable {

/// Adds a comment to this activity.
///
/// Attachments in [ActivityAddCommentRequest.attachmentUploads] are uploaded
/// first, and the comment is added once they are all in. To follow those
/// uploads or call them off, upload through
/// `StreamFeedsClient.attachmentUploader` instead and pass the results as
/// [ActivityAddCommentRequest.attachments].
///
/// Throws an [ArgumentError] if two of those attachments share an id. Ids
/// default to a fresh UUID, so this only happens when one is given
/// explicitly, or the same attachment is listed twice.
///
/// Returns a [Result] containing the created [CommentData] or an error.
Future<Result<CommentData>> addComment({
required ActivityAddCommentRequest request,
Expand All @@ -203,6 +213,16 @@ class Activity with Disposable {

/// Adds multiple comments to this activity in a batch.
///
/// Every request's attachments are uploaded as one batch, so a failure in one
/// can call off the uploads of the others. To follow those uploads or call
/// them off, upload through `StreamFeedsClient.attachmentUploader` instead
/// and pass the results as [ActivityAddCommentRequest.attachments].
///
/// Throws an [ArgumentError] if two attachments share an id, across the whole
/// batch rather than within one request — one batch cannot tell them apart.
/// Ids default to a fresh UUID, so this only happens when one is given
/// explicitly, or the same attachment is listed twice.
///
/// Returns a [Result] containing a list of created [CommentData] or an error.
Future<Result<List<CommentData>>> addCommentsBatch(
List<ActivityAddCommentRequest> requests,
Expand Down
10 changes: 10 additions & 0 deletions packages/stream_feeds/lib/src/state/feed.dart
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,16 @@ class Feed with Disposable {
///
/// The [request] contains the activity data to add.
///
/// Attachments in [FeedAddActivityRequest.attachmentUploads] are uploaded
/// first, and the activity is added once they are all in. To follow those
/// uploads or call them off, upload through
/// `StreamFeedsClient.attachmentUploader` instead and pass the results as
/// [FeedAddActivityRequest.attachments].
///
/// Throws an [ArgumentError] if two of those attachments share an id. Ids
/// default to a fresh UUID, so this only happens when one is given
/// explicitly, or the same attachment is listed twice.
Comment on lines +195 to +197

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This throw is documented on addActivity, addComment and addCommentsBatch, but only tested at the processRequestsBatch level in uploader_test.dart. Since the whole point is that it escapes a Result-returning method, one test at the public surface — addActivity with two attachments sharing an id — would pin the contract where callers actually meet it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added in 89b5f9a: addActivity with two attachments sharing an id, asserting throwsArgumentError and that nothing was posted. Pinned where callers meet the contract rather than at the processRequestsBatch seam, which was your point.

///
/// Returns a [Result] containing the added [ActivityData] if successful, or an error if the
/// operation fails.
Future<Result<ActivityData>> addActivity({
Expand Down
9 changes: 5 additions & 4 deletions packages/stream_feeds/lib/src/state/feed_state.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:math';

import 'package:clock/clock.dart';
import 'package:collection/collection.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:state_notifier/state_notifier.dart';
Expand Down Expand Up @@ -671,7 +672,7 @@ extension on FeedState {
final updatedNotificationStatus = notificationStatus?.copyWith(
unread: 0,
readActivities: readActivities,
lastReadAt: DateTime.timestamp(),
lastReadAt: clock.now().toUtc(),
);

return copyWith(notificationStatus: updatedNotificationStatus);
Expand All @@ -691,7 +692,7 @@ extension on FeedState {
final updatedNotificationStatus = notificationStatus?.copyWith(
unseen: 0,
seenActivities: seenActivities,
lastSeenAt: DateTime.timestamp(),
lastSeenAt: clock.now().toUtc(),
);

return copyWith(notificationStatus: updatedNotificationStatus);
Expand All @@ -715,7 +716,7 @@ extension on FeedState {
final updatedNotificationStatus = notificationStatus?.copyWith(
unread: updatedUnreadCount,
readActivities: updatedReadActivities,
lastReadAt: DateTime.timestamp(),
lastReadAt: clock.now().toUtc(),
);

return copyWith(notificationStatus: updatedNotificationStatus);
Expand All @@ -739,7 +740,7 @@ extension on FeedState {
final updatedNotificationStatus = notificationStatus?.copyWith(
unseen: updatedUnseenCount,
seenActivities: updatedSeenActivities,
lastSeenAt: DateTime.timestamp(),
lastSeenAt: clock.now().toUtc(),
);

return copyWith(notificationStatus: updatedNotificationStatus);
Expand Down
12 changes: 8 additions & 4 deletions packages/stream_feeds/lib/src/utils/batcher.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'dart:async';

import 'package:clock/clock.dart';

class Batcher<T, R> {
Batcher({
required this.action,
Expand All @@ -23,20 +25,22 @@ class Batcher<T, R> {
}

_itemsToProcess.add(item);
_nextActionCompleter ??= _planBatchFetch();
return _nextActionCompleter!.future;

// `_planBatchFetch` owns this field, and clears it when it runs on the spot.
final completer = _nextActionCompleter ?? _planBatchFetch();
return completer.future;
}

void dispose() {
_timer?.cancel();
}

Completer<R> _planBatchFetch() {
final timeSinceLastRun = DateTime.now().difference(_lastRun);
final timeSinceLastRun = clock.now().difference(_lastRun);

final newActionCompleter = Completer<R>();
_nextActionCompleter = newActionCompleter;
_lastRun = DateTime.now();
_lastRun = clock.now();

if (timeSinceLastRun >= interval) {
_runBatch();
Expand Down
Loading
Loading