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
2 changes: 2 additions & 0 deletions Assets/Plugins/StreamChat/Changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Features:

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
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
60 changes: 57 additions & 3 deletions Assets/Plugins/StreamChat/Core/StreamChatClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1141,14 +1141,68 @@ 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()
{
int failed = 0;
foreach (IStreamChannel channel in WatchedChannels.ToList())
{
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}");
}
}

return LowLevelClient.FetchAndProcessEventsSinceLastReceivedEvent(WatchedChannels.Select(c => c.Cid));
if (failed > 0)
{
_logs.Warning($"Re-watch completed with {failed} channel(s) unrestored.");
}
}

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