diff --git a/PowerSync/PowerSync.Common/CHANGELOG.md b/PowerSync/PowerSync.Common/CHANGELOG.md
index f786650..ed08898 100644
--- a/PowerSync/PowerSync.Common/CHANGELOG.md
+++ b/PowerSync/PowerSync.Common/CHANGELOG.md
@@ -1,5 +1,9 @@
# PowerSync.Common Changelog
+## 0.1.3 (unreleased)
+
+- Fix streaming sync retry loop reconnecting with no delay after an exception, ignoring `RetryDelayMs`.
+
## 0.1.2
- Add support for MacCatalyst.
diff --git a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs
index b1abb88..0dc6417 100644
--- a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs
+++ b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs
@@ -369,6 +369,11 @@ protected async Task StreamingSync(CancellationToken? signal, PowerSyncConnectio
break;
}
iterationResult = await StreamingSyncIteration(nestedCts.Token, options);
+
+ if (iterationResult.ImmediateRestart == true || iterationResult.LegacyRetry == true)
+ {
+ shouldDelayRetry = false;
+ }
}
catch (Exception ex)
{
@@ -413,20 +418,15 @@ protected async Task StreamingSync(CancellationToken? signal, PowerSyncConnectio
nestedCts = new CancellationTokenSource();
}
- if (iterationResult != null && (iterationResult.ImmediateRestart != true && iterationResult.LegacyRetry != true))
+ if (shouldDelayRetry)
{
-
UpdateSyncStatus(new SyncStatusOptions
{
Connected = false,
Connecting = true
});
- // On error, wait a little before retrying
- if (shouldDelayRetry)
- {
- await DelayRetry();
- }
+ await DelayRetry();
}
}
}
diff --git a/PowerSync/PowerSync.Maui/CHANGELOG.md b/PowerSync/PowerSync.Maui/CHANGELOG.md
index 79b9c7b..76f8a88 100644
--- a/PowerSync/PowerSync.Maui/CHANGELOG.md
+++ b/PowerSync/PowerSync.Maui/CHANGELOG.md
@@ -1,5 +1,9 @@
# PowerSync.Maui Changelog
+## 0.1.3 (unreleased)
+
+- Upstream PowerSync.Common version bump (See Powersync.Common changelog 0.1.3 for more information)
+
## 0.1.2
- Add support for MacCatalyst.
diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/StreamingSyncRetryTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/StreamingSyncRetryTests.cs
new file mode 100644
index 0000000..659af87
--- /dev/null
+++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/StreamingSyncRetryTests.cs
@@ -0,0 +1,106 @@
+namespace PowerSync.Common.Tests.Client.Sync;
+
+using System.Collections.Concurrent;
+
+using PowerSync.Common.Client;
+using PowerSync.Common.Client.Connection;
+using PowerSync.Common.Client.Sync.Stream;
+using PowerSync.Common.Tests.Utils;
+using PowerSync.Common.Tests.Utils.Sync;
+
+///
+/// dotnet test -v n --framework net8.0 --filter "StreamingSyncRetryTests"
+///
+public class StreamingSyncRetryTests
+{
+ [Fact(Timeout = 15000)]
+ public async Task RetryLoop_AppliesDelayBetweenFailedAttempts()
+ {
+ const int retryDelayMs = 200;
+ const double tolerance = 0.75;
+
+ var attemptTimes = new ConcurrentQueue();
+ var attemptSignal = new SemaphoreSlim(0);
+
+ var dbFilename = $"sync-retry-{Guid.NewGuid():N}.db";
+ var throwing = new ThrowingRemote(new TestConnector(), attemptTimes, attemptSignal);
+
+ var db = new PowerSyncDatabase(new PowerSyncDatabaseOptions
+ {
+ Database = new SQLOpenOptions { DbFilename = dbFilename },
+ Schema = TestSchemaTodoList.AppSchema,
+ RemoteFactory = _ => throwing
+ });
+
+ try
+ {
+ await db.Init();
+
+ // Fire-and-forget: Connect() awaits Connected=true, which never fires
+ // because every iteration throws. The retry loop runs in the background.
+ _ = db.Connect(
+ new TestConnector(),
+ new PowerSyncConnectionOptions { RetryDelayMs = retryDelayMs }
+ );
+
+ for (int i = 0; i < 4; i++)
+ {
+ Assert.True(
+ await attemptSignal.WaitAsync(TimeSpan.FromSeconds(5)),
+ $"Did not observe attempt #{i + 1} within timeout — retry loop is not running"
+ );
+ }
+
+ var timestamps = attemptTimes.ToArray();
+ Assert.True(timestamps.Length >= 4);
+
+ for (int i = 1; i < timestamps.Length; i++)
+ {
+ var deltaMs = (timestamps[i] - timestamps[i - 1]).TotalMilliseconds;
+ Assert.True(
+ deltaMs >= retryDelayMs * tolerance,
+ $"Retry gap #{i} was {deltaMs:F0}ms, expected >= {retryDelayMs * tolerance:F0}ms (RetryDelayMs={retryDelayMs})"
+ );
+ }
+ }
+ finally
+ {
+ await db.Disconnect();
+ await db.Close();
+ DatabaseUtils.CleanDb(dbFilename);
+ }
+ }
+}
+
+internal sealed class ThrowingRemote : Remote
+{
+ private readonly ConcurrentQueue timestamps;
+ private readonly SemaphoreSlim signal;
+
+ public ThrowingRemote(
+ IPowerSyncBackendConnector connector,
+ ConcurrentQueue timestamps,
+ SemaphoreSlim signal
+ ) : base(connector)
+ {
+ this.timestamps = timestamps;
+ this.signal = signal;
+ }
+
+ public override Task PostStreamRaw(SyncStreamOptions options)
+ {
+ timestamps.Enqueue(DateTime.UtcNow);
+ signal.Release();
+ throw new HttpRequestException(
+ "HTTP InternalServerError: simulated [PSYNC_S2305] from ThrowingRemote"
+ );
+ }
+
+ public override Task Get(string path, Dictionary? headers = null)
+ {
+ var response = new StreamingSyncImplementation.ApiResponse(
+ new StreamingSyncImplementation.ResponseData("1")
+ );
+ return Task.FromResult((T)(object)response);
+ }
+}