Skip to content
Open
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
3 changes: 3 additions & 0 deletions Assets/Plugins/StreamChat/Changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ Features:

* Add a public StreamApiException constructor (statusCode, code, errorMessage, moreInfo, duration, exceptionFields). StreamApiException is a public, catch-and-branch type (via the StreamApiExceptionExtensions.Is* helpers), but until now it could only be constructed inside the SDK from the internal APIErrorInternalDTO, so integrators could not build one to unit-test their own error handling (e.g. simulating a 403 / code 70 "no access to channels" response). The new constructor maps directly to the type's public properties and keeps APIErrorInternalDTO internal.
* Add IStreamClientConfig.OptimisticMessageInsert (default true). When true (the existing behavior), a message you send is inserted into the local channel state and raised via IStreamChannel.MessageReceived immediately, before the server's message.new echo arrives. Set it to false to skip the optimistic local insert and wait for the server echo instead, so every participant - including the sender - observes messages in the same server-defined order. Useful when consistent cross-client ordering matters more than instant local feedback (e.g. a shared, broadcast-ordered feed).
* Add IStreamChatClient.ChannelsRewatched, raised with the channels whose local state a reconnect re-watch just replaced. A re-watch raises no per-message events for the window it replaced, so handle this to rebuild your UI from IStreamChannel.Messages.

Fixes:

* Fix watched channels silently staying stale after a reconnect whose /sync catch-up the server refuses as too large ("Too many events to sync", HTTP 400 / code 4 - reachable after a long disconnect on a busy channel, since the ~1000-event limit counts events across every cid in the call). The failure previously reached only the fire-and-forget logger, and the stale sync point was kept so every subsequent reconnect failed identically. The SDK now drops the stale sync point and re-watches every watched channel instead, which is the same full state fetch the initial watch performs. Each channel is restored independently so one failure (e.g. a 403 on a channel deleted while offline) does not abandon the rest.
* Fix the 30-day staleness guard on the reconnect /sync catch-up computing its age backwards (lastEventReceivedAt - now, which is negative for any past timestamp), so the guard never fired and /sync was called even with a LastSyncAt the server rejects. Also removes a dead local that was almost certainly the intended operand.
* TaskUtils.LogIfFailed now logs connectivity/transport failures as warnings instead of errors/exceptions. The SDK fire-and-forgets its connect, reconnect, and state-restore operations through LogIfFailed; when the device is offline these fail with HttpRequestException / WebException / SocketException / IOException / TimeoutException, which the reconnect flow recovers from - so surfacing them at error severity flooded crash/error reporting (Sentry, Bugsnag, etc.) with handled, non-actionable noise. Genuine (non-connectivity) failures still log as exceptions. Complements the connection-attempt-timeout fix from PR #213.

v5.5.0:
Expand Down
8 changes: 8 additions & 0 deletions Assets/Plugins/StreamChat/Core/IStreamChatClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ public interface IStreamChatClient : IDisposable, IStreamChatClientEventsListene
/// </summary>
event ChannelMemberRemovedHandler RemovedFromChannelAsMember;

/// <summary>
/// Channels whose local state was just replaced wholesale by a reconnect re-watch, which
/// raises no per-message events for the window it replaced. A consumer rendering one of
/// these channels must rebuild from <see cref="IStreamChannel.Messages"/> rather than wait
/// for message events, otherwise it keeps showing the rows it had before the disconnect.
/// </summary>
event ChannelsRewatchedHandler ChannelsRewatched;

/// <summary>
/// Raised when an <see cref="IStreamThread"/> becomes available locally. Use this to bind
/// per-thread UI and to subscribe to the thread's own events such as
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using StreamChat.Core.LowLevelClient.API.Internal;
using StreamChat.Core.LowLevelClient.Events;
using StreamChat.Core.LowLevelClient.Models;
using StreamChat.Core.LowLevelClient.Responses;
using StreamChat.Core.Web;
using StreamChat.Libs;
using StreamChat.Libs.AppInfo;
Expand Down Expand Up @@ -445,23 +446,38 @@ public async Task FetchAndProcessEventsSinceLastReceivedEvent(IEnumerable<string

var lastEventReceivedAt = _disconnectionLastEventReceivedAt.Value;

var currentServerTime = DateTimeOffset.UtcNow.ToOffset(lastEventReceivedAt.Offset);

// Check if less than 30 days
var diff = lastEventReceivedAt - _timeService.Now;
// Check if less than 30 days. Past that the server rejects LastSyncAt, so this only
// skips a request that was certain to fail — it is not a recovery path. The SDK has no
// re-hydrate fallback of its own, so bridging a gap this large is the consumer's job.
TimeSpan diff = _timeService.Now - lastEventReceivedAt;
if (diff.TotalDays > 30)
{
return;
}

//StreamTodo: according to Android SDK there's an error if there are > 1000 events

var response = await ChannelApi.SyncAsync(new SyncRequest
SyncResponse response;
try
{
ChannelCids = channelCids.ToList(),
LastSyncAt = lastEventReceivedAt,
Watch = true,
});
response = await ChannelApi.SyncAsync(new SyncRequest
{
ChannelCids = channelCids.ToList(),
LastSyncAt = lastEventReceivedAt,
Watch = true,
});
}
catch (StreamApiException e) when (e.IsInputError())
{
// The gap is too large for /sync — more than the ~1000 events the server
// will replay (the StreamTodo above), which a busy channel reaches long before the
// 30-day bound checked above. Drop the sync point so the next reconnect starts from
// a fresh one instead of failing the same way forever, and let the caller re-hydrate
// the channels: the missed events are gone either way, and only a full state fetch
// brings the watched channels back up to date.
_disconnectionLastEventReceivedAt = null;
throw;
}

if (response.Events.Count == 0)
{
Expand Down
75 changes: 72 additions & 3 deletions Assets/Plugins/StreamChat/Core/StreamChatClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ namespace StreamChat.Core
/// </summary>
public delegate void ChannelMemberRemovedHandler(IStreamChannel channel, IStreamChannelMember member);

/// <summary>
/// Channels whose local state was replaced wholesale by a reconnect re-watch handler
/// </summary>
public delegate void ChannelsRewatchedHandler(IReadOnlyList<IStreamChannel> channels);

/// <inheritdoc cref="IStreamChatClient"/>
public sealed class StreamChatClient : IStreamChatClient
{
Expand All @@ -87,6 +92,8 @@ public sealed class StreamChatClient : IStreamChatClient
public event ChannelMemberAddedHandler AddedToChannelAsMember;
public event ChannelMemberRemovedHandler RemovedFromChannelAsMember;

public event ChannelsRewatchedHandler ChannelsRewatched;

public event StreamThreadChangeHandler ThreadTracked;
public event StreamThreadChangeHandler ThreadUntracked;

Expand Down Expand Up @@ -1141,14 +1148,76 @@ private void OnConnected(HealthCheckEventInternalDTO dto)
RestoreStateLostDuringDisconnect().LogIfFailed();
}

private Task RestoreStateLostDuringDisconnect()
private async Task RestoreStateLostDuringDisconnect()
{
if (!WatchedChannels.Any())
{
return Task.CompletedTask;
return;
}

try
{
await LowLevelClient.FetchAndProcessEventsSinceLastReceivedEvent(
WatchedChannels.Select(c => c.Cid));
}
catch (StreamApiException e) when (e.IsInputError())
{
// /sync refused the catch-up because too much accumulated while we were
// disconnected (see FetchAndProcessEventsSinceLastReceivedEvent). Without this the
// exception only reached the fire-and-forget logger at the call site: the watched
// channels silently stayed as they were before the disconnect, missing every
// message since, until something else happened to re-fetch them. Re-watch instead —
// it is the same full state fetch the initial watch does.
_logs.Warning("The /sync catch-up was refused as too large; re-watching " +
$"{WatchedChannels.Count} channel(s) to restore their state instead.");
await RewatchChannelsAsync();
}
}

// Full state re-fetch of every watched channel, used when /sync cannot bridge the
// disconnect gap. Snapshotted because each re-watch writes the cache the list is built from.
//
// Every channel is attempted independently. This runs AFTER the stale sync point has been
// dropped, so it is the only recovery this reconnect gets and there is no later retry: a
// single failure escaping the loop would leave every remaining channel silently stale for the
// rest of the session. Failures are expected here, not exotic — a channel torn down while we
// were offline returns 403 on every read, and a long watched list can trip a 429 part-way.
// Log each one and keep going so the channels that CAN be restored are.
//
// Known limitation: GetOrCreateChannelWithIdAsync is get-OR-CREATE, so re-watching a channel
// that was hard-deleted while we were offline recreates it server-side as an empty channel.
// Fixing that properly means consulting SyncResponse.InaccessibleCids (already returned by
// /sync and currently ignored) to skip channels the server says are gone, rather than
// discovering it one 403 at a time.
private async Task RewatchChannelsAsync()
{
List<IStreamChannel> channels = WatchedChannels.ToList();
int failed = 0;
foreach (IStreamChannel channel in channels)
{
try
{
await GetOrCreateChannelWithIdAsync(channel.Type, channel.Id);
}
catch (Exception e)
{
failed++;
_logs.Warning($"Re-watch failed for channel {channel.Type}:{channel.Id}; " +
$"its local state stays as it was before the disconnect. {e.Message}");
}
}

if (failed > 0)
{
_logs.Warning($"Re-watch completed with {failed} channel(s) unrestored.");
}

return LowLevelClient.FetchAndProcessEventsSinceLastReceivedEvent(WatchedChannels.Select(c => c.Cid));
// A re-watch replaces the channel's messages wholesale, without raising the per-message
// events a consumer would normally rebuild from, so without this signal an open UI keeps
// rendering the rows it had before the disconnect. Raised even when some channels failed:
// the ones that succeeded did have their state replaced, and a consumer rebuilding from a
// failed channel's (unchanged) list is harmless.
ChannelsRewatched?.Invoke(channels);
}

private void OnDisconnected() => Disconnected?.Invoke();
Expand Down
Loading