From 45ea5f7b5efb889f79b115719c74101e03eacc14 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 3 Sep 2026 11:30:02 +0200 Subject: [PATCH 1/6] fix(switch): stop the transport from reconnecting blindly while offline UnityWebRequestTransport opens a connection per envelope. On Nintendo Switch every attempt made while the console is offline raises the system "connect to the internet" dialog, and because logs and metrics each flush on a 5s timer that turned into a prompt every couple of seconds. A connection error previously only logged a warning and returned null, which also skipped the rate-limit handling in HttpTransportBase, so nothing anywhere recorded that the network was down. Add two guards to SendEnvelopeAsync: skip sending entirely while the platform reports NotReachable, and back off exponentially (1s up to 60s) after a connection error, resetting on the first success. The backoff is what covers platforms whose reachability cannot be trusted, and the case where the network drops after startup. Both paths record DiscardReason.NetworkError so client reports still account for the drops. Reachability goes through IApplication rather than UnityEngine.Application so the behaviour is testable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ABHTqM37qGAc9iQZcdbdY8 --- CHANGELOG.md | 3 + src/Sentry.Unity/Integrations/IApplication.cs | 3 + src/Sentry.Unity/UnityWebRequestTransport.cs | 99 ++++++++++++++++++- .../UnityWebRequestTransportTests.cs | 47 +++++++++ test/SharedClasses/TestApplication.cs | 1 + 5 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 test/Sentry.Unity.Tests/UnityWebRequestTransportTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1271dd85f..89a10c983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ ### Fixes - IL2CPP line numbers now work on Android x86/x86_64 builds. il2cpp fails to report the image UUID there, so the SDK falls back to looking the debug image up by name ([#2817](https://github.com/getsentry/sentry-unity/pull/2817)) +### Fixes + +- The `UnityWebRequestTransport` no longer opens a connection while the platform reports no network, and backs off exponentially (1s up to 60s) after a connection error instead of retrying on every envelope. On Nintendo Switch each send attempt made while offline raises the system's "connect to the internet" dialog, so a game with logs or metrics enabled produced a prompt every few seconds ([#TBD](https://github.com/getsentry/sentry-unity/pull/TBD)) ### Dependencies diff --git a/src/Sentry.Unity/Integrations/IApplication.cs b/src/Sentry.Unity/Integrations/IApplication.cs index 805c63543..cca143b4d 100644 --- a/src/Sentry.Unity/Integrations/IApplication.cs +++ b/src/Sentry.Unity/Integrations/IApplication.cs @@ -17,6 +17,7 @@ internal interface IApplication string UnityVersion { get; } string PersistentDataPath { get; } RuntimePlatform Platform { get; } + NetworkReachability InternetReachability { get; } } /// @@ -54,6 +55,8 @@ private ApplicationAdapter() public RuntimePlatform Platform => Application.platform; + public NetworkReachability InternetReachability => Application.internetReachability; + private void OnLogMessageReceived(string condition, string stackTrace, LogType type) => LogMessageReceived?.Invoke(condition, stackTrace, type); diff --git a/src/Sentry.Unity/UnityWebRequestTransport.cs b/src/Sentry.Unity/UnityWebRequestTransport.cs index c19a18d8c..2c502f388 100644 --- a/src/Sentry.Unity/UnityWebRequestTransport.cs +++ b/src/Sentry.Unity/UnityWebRequestTransport.cs @@ -6,7 +6,11 @@ using System.Threading.Tasks; using Sentry.Extensibility; using Sentry.Http; +using Sentry.Internal; +using Sentry.Internal.Extensions; using Sentry.Protocol.Envelopes; +using Sentry.Unity.Integrations; +using UnityEngine; using UnityEngine.Networking; namespace Sentry.Unity; @@ -51,13 +55,36 @@ internal class UnityWebRequestTransport : HttpTransportBase { private readonly SentryUnityOptions _options; - public UnityWebRequestTransport(SentryUnityOptions options) + // This transport opens a connection per envelope. On platforms that raise a system dialog + // when the game reaches for an unavailable network - Nintendo Switch prompts to connect to + // the internet - every attempt made while offline puts that dialog on screen. A game with + // logs and metrics enabled flushes an envelope every few seconds, so a blind retry per + // envelope turns into a steady stream of prompts. Two guards bound that: the reachability + // check skips sending altogether while the platform reports no network, and the backoff + // spaces out attempts on platforms where reachability cannot be trusted. + private const double InitialBackoffSeconds = 1.0; + private const double MaxBackoffSeconds = 60.0; + + private readonly IApplication _application; + + private int _consecutiveConnectionErrors; + private double _retryAfterRealtime; + + public UnityWebRequestTransport(SentryUnityOptions options, IApplication? application = null) : base(options) - => _options = options; + { + _options = options; + _application = application ?? ApplicationAdapter.Instance; + } // adapted HttpTransport.SendEnvelopeAsync() internal IEnumerator SendEnvelopeAsync(Envelope envelope) { + if (!CanAttemptSend(envelope)) + { + yield break; + } + using var processedEnvelope = ProcessEnvelope(envelope); if (processedEnvelope.Items.Count > 0) { @@ -66,6 +93,14 @@ internal IEnumerator SendEnvelopeAsync(Envelope envelope) var www = CreateWebRequest(httpRequest); yield return www.SendWebRequest(); + if (www.result == UnityWebRequest.Result.ConnectionError) + { + OnConnectionError(www, processedEnvelope); + yield break; + } + + OnConnectionSucceeded(); + var response = GetResponse(www); if (response is not null) { @@ -74,6 +109,66 @@ internal IEnumerator SendEnvelopeAsync(Envelope envelope) } } + /// + /// Whether it is worth opening a connection for this envelope. Envelopes dropped here are + /// recorded as discarded so client reports still account for them. + /// + private bool CanAttemptSend(Envelope envelope) + { + if (_application.InternetReachability == NetworkReachability.NotReachable) + { + _options.LogDebug("No network available. Dropping envelope instead of attempting to send."); + _options.ClientReportRecorder.RecordDiscardedEvents(DiscardReason.NetworkError, envelope); + return false; + } + + if (_consecutiveConnectionErrors > 0) + { + var remaining = _retryAfterRealtime - Time.realtimeSinceStartupAsDouble; + if (remaining > 0.0) + { + _options.LogDebug( + "Backing off after {0} failed connection attempt(s). Dropping envelope, retrying in {1:F1}s.", + _consecutiveConnectionErrors, remaining); + _options.ClientReportRecorder.RecordDiscardedEvents(DiscardReason.NetworkError, envelope); + return false; + } + } + + return true; + } + + private void OnConnectionError(UnityWebRequest www, Envelope envelope) + { + _consecutiveConnectionErrors++; + + var backoff = Math.Min( + MaxBackoffSeconds, + InitialBackoffSeconds * Math.Pow(2, _consecutiveConnectionErrors - 1)); + _retryAfterRealtime = Time.realtimeSinceStartupAsDouble + backoff; + + // Reachability is logged here because a platform reporting itself reachable while the + // connection fails is exactly the case the backoff exists to cover. + var backoffDetail = $"{backoff:F1}s (consecutive failure #{_consecutiveConnectionErrors})"; + _options.LogWarning( + "Failed to send request: {0}. Reachability reported as {1}. Backing off for {2}.", + www.error, _application.InternetReachability, backoffDetail); + + _options.ClientReportRecorder.RecordDiscardedEvents(DiscardReason.NetworkError, envelope); + } + + private void OnConnectionSucceeded() + { + if (_consecutiveConnectionErrors == 0) + { + return; + } + + _options.LogDebug("Connection restored after {0} failed attempt(s).", _consecutiveConnectionErrors); + _consecutiveConnectionErrors = 0; + _retryAfterRealtime = 0.0; + } + private UnityWebRequest CreateWebRequest(HttpRequestMessage message) { using var contentStream = ReadStreamFromHttpContent(message.Content); diff --git a/test/Sentry.Unity.Tests/UnityWebRequestTransportTests.cs b/test/Sentry.Unity.Tests/UnityWebRequestTransportTests.cs new file mode 100644 index 000000000..10cd48b8b --- /dev/null +++ b/test/Sentry.Unity.Tests/UnityWebRequestTransportTests.cs @@ -0,0 +1,47 @@ +using NUnit.Framework; +using Sentry.Protocol.Envelopes; +using Sentry.Unity.Tests.Stubs; +using UnityEngine; + +namespace Sentry.Unity.Tests; + +[TestFixture] +public class UnityWebRequestTransportTests +{ + private static SentryUnityOptions CreateOptions() => new() + { + Dsn = SentryTests.TestDsn + }; + + private static Envelope CreateEnvelope() => Envelope.FromEvent(new SentryEvent()); + + /// + /// The coroutine has to bail before its first yield. Anything else means a + /// got created, which is what raises the + /// system connection dialog on platforms like the Nintendo Switch. + /// + [Test] + public void SendEnvelopeAsync_NetworkNotReachable_DoesNotOpenAConnection() + { + var application = new TestApplication { InternetReachability = NetworkReachability.NotReachable }; + var transport = new UnityWebRequestTransport(CreateOptions(), application); + + var enumerator = transport.SendEnvelopeAsync(CreateEnvelope()); + + Assert.IsFalse(enumerator.MoveNext(), "The transport attempted to send while offline."); + } + + [Test] + public void SendEnvelopeAsync_NetworkReachable_AttemptsToSend() + { + var application = new TestApplication + { + InternetReachability = NetworkReachability.ReachableViaLocalAreaNetwork + }; + var transport = new UnityWebRequestTransport(CreateOptions(), application); + + var enumerator = transport.SendEnvelopeAsync(CreateEnvelope()); + + Assert.IsTrue(enumerator.MoveNext(), "The transport did not attempt to send while online."); + } +} diff --git a/test/SharedClasses/TestApplication.cs b/test/SharedClasses/TestApplication.cs index 45ed584f9..2051fc3c0 100644 --- a/test/SharedClasses/TestApplication.cs +++ b/test/SharedClasses/TestApplication.cs @@ -41,6 +41,7 @@ public TestApplication( public string UnityVersion { get; set; } public string PersistentDataPath { get; set; } public RuntimePlatform Platform { get; set; } + public NetworkReachability InternetReachability { get; set; } = NetworkReachability.ReachableViaLocalAreaNetwork; private void OnLogMessageReceived(string condition, string stacktrace, LogType type) => LogMessageReceived?.Invoke(condition, stacktrace, type); From 5b414f8f13e595f1b2d1a977ece30b0ad4e67654 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 4 Sep 2026 12:25:42 +0200 Subject: [PATCH 2/6] fix(switch): ask the platform for network availability instead of reachability Application.internetReachability reports a Switch console as reachable while it is offline - the device logs show "Reachability reported as ReachableViaLocalAreaNetwork" next to every failed send. The reachability gate in the transport was therefore inert on the one platform it was meant to protect, leaving the connection-error backoff to absorb everything. Since attempting the connection is itself what raises the system "connect to the internet" dialog, that still meant a dialog per attempt, just at the backoff's cadence. Let the platform answer instead. sentry-switch now exposes sentry_switch_utils_is_network_available(), which asks the console's network interface manager without submitting a network request, so asking costs nothing and shows no dialog. SentryNativeSwitch hands it to the options as NetworkAvailabilityProbe and the transport prefers it, falling back to reachability where no probe is set - which keeps WebGL and the unknown-platform path behaving as before. The stub returns 1 so builds without the native library keep today's behaviour and rely on the backoff. Requires a sentry-switch build that exports the new function: the P/Invoke goes through __Internal against a statically linked archive, so it resolves at link time rather than falling back at runtime. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ABHTqM37qGAc9iQZcdbdY8 --- CHANGELOG.md | 5 ++- .../Plugins/Switch/sentry_native_stubs.c | 7 ++++ src/Sentry.Unity.Native/SentryNativeSwitch.cs | 18 ++++++++++ src/Sentry.Unity/SentryUnityOptions.cs | 12 +++++++ src/Sentry.Unity/UnityWebRequestTransport.cs | 17 +++++++++- .../UnityWebRequestTransportTests.cs | 34 +++++++++++++++++++ 6 files changed, 89 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89a10c983..0f79850d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,8 @@ ### Fixes - IL2CPP line numbers now work on Android x86/x86_64 builds. il2cpp fails to report the image UUID there, so the SDK falls back to looking the debug image up by name ([#2817](https://github.com/getsentry/sentry-unity/pull/2817)) -### Fixes - -- The `UnityWebRequestTransport` no longer opens a connection while the platform reports no network, and backs off exponentially (1s up to 60s) after a connection error instead of retrying on every envelope. On Nintendo Switch each send attempt made while offline raises the system's "connect to the internet" dialog, so a game with logs or metrics enabled produced a prompt every few seconds ([#TBD](https://github.com/getsentry/sentry-unity/pull/TBD)) +- The `UnityWebRequestTransport` no longer opens a connection while the platform reports no network, and backs off exponentially (1s up to 60s) after a connection error instead of retrying on every envelope. On Nintendo Switch each send attempt made while offline raises the system's "connect to the internet" dialog, so a game with logs or metrics enabled produced a prompt every few seconds ([#2833](https://github.com/getsentry/sentry-unity/pull/2833)) +- On Nintendo Switch the SDK now asks the native SDK whether the network is usable before sending, instead of relying on `Application.internetReachability`, which reports the console as reachable while it is offline. Every send attempted in that state raised the system's "connect to the internet" dialog. Requires a sentry-switch build exposing `sentry_switch_utils_is_network_available()` ([#2833](https://github.com/getsentry/sentry-unity/pull/2833)) ### Dependencies diff --git a/package-dev/Plugins/Switch/sentry_native_stubs.c b/package-dev/Plugins/Switch/sentry_native_stubs.c index 04836e9e0..fcc0e5962 100644 --- a/package-dev/Plugins/Switch/sentry_native_stubs.c +++ b/package-dev/Plugins/Switch/sentry_native_stubs.c @@ -408,6 +408,13 @@ const char* sentry_switch_utils_get_default_user_id(void) return ""; } +int sentry_switch_utils_is_network_available(void) +{ + /* Return 1 - with no native SDK to ask, assume the network is usable and let the + transport fall back on its connection-error backoff */ + return 1; +} + /* * ============================================================================= * Utility Functions diff --git a/src/Sentry.Unity.Native/SentryNativeSwitch.cs b/src/Sentry.Unity.Native/SentryNativeSwitch.cs index 6fab13909..4ca0b243d 100644 --- a/src/Sentry.Unity.Native/SentryNativeSwitch.cs +++ b/src/Sentry.Unity.Native/SentryNativeSwitch.cs @@ -28,8 +28,21 @@ public static class SentryNativeSwitch [DllImport("__Internal")] private static extern IntPtr sentry_switch_utils_get_default_user_id(); + [DllImport("__Internal")] + private static extern int sentry_switch_utils_is_network_available(); + private static IDiagnosticLogger? Logger; + /// + /// Queries the network interface manager without submitting a network request. + /// + /// + /// Unity's Application.internetReachability reports the console as reachable even while + /// it is offline, and every send attempted in that state raises the system's "connect to the + /// internet" dialog. Asking the native SDK first is what keeps the dialog off the screen. + /// + internal static bool IsNetworkAvailable() => sentry_switch_utils_is_network_available() == 1; + /// /// Configures the native support for Nintendo Switch. /// @@ -72,6 +85,11 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf return; } + // Wired up before the storage and native SDK setup below: the probe only needs the native + // library to be linked, so the transport keeps the benefit even if either of those fails. + Logger?.LogDebug("Using the native SDK to determine network availability."); + options.NetworkAvailabilityProbe = IsNetworkAvailable; + Logger?.LogDebug("Mounting temporary storage for sentry-switch."); if (sentry_switch_utils_mount() != 1) diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 1cb15ad5b..393ee4a1c 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -493,6 +493,18 @@ internal string? DefaultUserId /// internal Action? NativeSupportCloseCallback { get; set; } = null; + /// + /// Reports whether the network is currently usable, when the platform can answer that more + /// reliably than . + /// + /// + /// Set by the platform configuration where a native probe exists. On Nintendo Switch this + /// matters twice over: reachability reports the console as reachable while it is offline, and + /// merely attempting a connection there raises the system's "connect to the internet" dialog. + /// Must not block - it is called on the main thread before each send. + /// + internal Func? NetworkAvailabilityProbe { get; set; } = null; + internal List SdkIntegrationNames { get; set; } = new(); internal ISentryUnityInfo UnityInfo { get; private set; } diff --git a/src/Sentry.Unity/UnityWebRequestTransport.cs b/src/Sentry.Unity/UnityWebRequestTransport.cs index 2c502f388..2a27eb02f 100644 --- a/src/Sentry.Unity/UnityWebRequestTransport.cs +++ b/src/Sentry.Unity/UnityWebRequestTransport.cs @@ -109,13 +109,28 @@ internal IEnumerator SendEnvelopeAsync(Envelope envelope) } } + /// + /// Whether the platform reports a usable network. Prefers the platform's own probe where one + /// is available, because Unity's reachability cannot always be trusted - on Nintendo Switch it + /// reports the console as reachable while it is offline. + /// + private bool IsNetworkAvailable() + { + if (_options.NetworkAvailabilityProbe is { } probe) + { + return probe(); + } + + return _application.InternetReachability != NetworkReachability.NotReachable; + } + /// /// Whether it is worth opening a connection for this envelope. Envelopes dropped here are /// recorded as discarded so client reports still account for them. /// private bool CanAttemptSend(Envelope envelope) { - if (_application.InternetReachability == NetworkReachability.NotReachable) + if (!IsNetworkAvailable()) { _options.LogDebug("No network available. Dropping envelope instead of attempting to send."); _options.ClientReportRecorder.RecordDiscardedEvents(DiscardReason.NetworkError, envelope); diff --git a/test/Sentry.Unity.Tests/UnityWebRequestTransportTests.cs b/test/Sentry.Unity.Tests/UnityWebRequestTransportTests.cs index 10cd48b8b..3179edf88 100644 --- a/test/Sentry.Unity.Tests/UnityWebRequestTransportTests.cs +++ b/test/Sentry.Unity.Tests/UnityWebRequestTransportTests.cs @@ -44,4 +44,38 @@ public void SendEnvelopeAsync_NetworkReachable_AttemptsToSend() Assert.IsTrue(enumerator.MoveNext(), "The transport did not attempt to send while online."); } + + /// + /// The platform probe has the final say. On Nintendo Switch reachability reports the console as + /// reachable while it is offline, and attempting the send is what raises the system dialog - so + /// a probe saying "no network" has to win over reachability saying otherwise. + /// + [Test] + public void SendEnvelopeAsync_ProbeReportsNoNetwork_OverridesReachability() + { + var application = new TestApplication + { + InternetReachability = NetworkReachability.ReachableViaLocalAreaNetwork + }; + var options = CreateOptions(); + options.NetworkAvailabilityProbe = () => false; + var transport = new UnityWebRequestTransport(options, application); + + var enumerator = transport.SendEnvelopeAsync(CreateEnvelope()); + + Assert.IsFalse(enumerator.MoveNext(), "The transport ignored the platform's network probe."); + } + + [Test] + public void SendEnvelopeAsync_ProbeReportsNetwork_OverridesReachability() + { + var application = new TestApplication { InternetReachability = NetworkReachability.NotReachable }; + var options = CreateOptions(); + options.NetworkAvailabilityProbe = () => true; + var transport = new UnityWebRequestTransport(options, application); + + var enumerator = transport.SendEnvelopeAsync(CreateEnvelope()); + + Assert.IsTrue(enumerator.MoveNext(), "The transport ignored the platform's network probe."); + } } From 4ff9f05c6248aef8d256f93c6d0c5fa285e1176a Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 4 Sep 2026 15:49:58 +0200 Subject: [PATCH 3/6] comments --- .../Plugins/Switch/sentry_native_stubs.c | 3 +-- src/Sentry.Unity.Native/SentryNativeSwitch.cs | 10 ++++---- src/Sentry.Unity/SentryUnityOptions.cs | 9 +++---- src/Sentry.Unity/UnityWebRequestTransport.cs | 24 ++++++++----------- .../UnityWebRequestTransportTests.cs | 10 ++++---- 5 files changed, 22 insertions(+), 34 deletions(-) diff --git a/package-dev/Plugins/Switch/sentry_native_stubs.c b/package-dev/Plugins/Switch/sentry_native_stubs.c index fcc0e5962..57ffbf7d8 100644 --- a/package-dev/Plugins/Switch/sentry_native_stubs.c +++ b/package-dev/Plugins/Switch/sentry_native_stubs.c @@ -410,8 +410,7 @@ const char* sentry_switch_utils_get_default_user_id(void) int sentry_switch_utils_is_network_available(void) { - /* Return 1 - with no native SDK to ask, assume the network is usable and let the - transport fall back on its connection-error backoff */ + /* No native SDK to ask - assume usable and let the transport's backoff take over */ return 1; } diff --git a/src/Sentry.Unity.Native/SentryNativeSwitch.cs b/src/Sentry.Unity.Native/SentryNativeSwitch.cs index 4ca0b243d..4f139a2c9 100644 --- a/src/Sentry.Unity.Native/SentryNativeSwitch.cs +++ b/src/Sentry.Unity.Native/SentryNativeSwitch.cs @@ -34,12 +34,11 @@ public static class SentryNativeSwitch private static IDiagnosticLogger? Logger; /// - /// Queries the network interface manager without submitting a network request. + /// Whether the console currently has a usable network. /// /// - /// Unity's Application.internetReachability reports the console as reachable even while - /// it is offline, and every send attempted in that state raises the system's "connect to the - /// internet" dialog. Asking the native SDK first is what keeps the dialog off the screen. + /// Application.internetReachability reports the console as reachable while it is offline, + /// and every send attempted in that state raises the system's "connect to the internet" dialog. /// internal static bool IsNetworkAvailable() => sentry_switch_utils_is_network_available() == 1; @@ -85,8 +84,7 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf return; } - // Wired up before the storage and native SDK setup below: the probe only needs the native - // library to be linked, so the transport keeps the benefit even if either of those fails. + // The probe only needs the native library linked, so it survives a failure of the setup below. Logger?.LogDebug("Using the native SDK to determine network availability."); options.NetworkAvailabilityProbe = IsNetworkAvailable; diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 393ee4a1c..4dcb300e0 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -494,14 +494,11 @@ internal string? DefaultUserId internal Action? NativeSupportCloseCallback { get; set; } = null; /// - /// Reports whether the network is currently usable, when the platform can answer that more - /// reliably than . + /// Reports whether the network is usable on platforms where + /// cannot be trusted. /// /// - /// Set by the platform configuration where a native probe exists. On Nintendo Switch this - /// matters twice over: reachability reports the console as reachable while it is offline, and - /// merely attempting a connection there raises the system's "connect to the internet" dialog. - /// Must not block - it is called on the main thread before each send. + /// Must not block - the transport calls this on the main thread before each send. /// internal Func? NetworkAvailabilityProbe { get; set; } = null; diff --git a/src/Sentry.Unity/UnityWebRequestTransport.cs b/src/Sentry.Unity/UnityWebRequestTransport.cs index 2a27eb02f..f5aeeeee2 100644 --- a/src/Sentry.Unity/UnityWebRequestTransport.cs +++ b/src/Sentry.Unity/UnityWebRequestTransport.cs @@ -55,13 +55,10 @@ internal class UnityWebRequestTransport : HttpTransportBase { private readonly SentryUnityOptions _options; - // This transport opens a connection per envelope. On platforms that raise a system dialog - // when the game reaches for an unavailable network - Nintendo Switch prompts to connect to - // the internet - every attempt made while offline puts that dialog on screen. A game with - // logs and metrics enabled flushes an envelope every few seconds, so a blind retry per - // envelope turns into a steady stream of prompts. Two guards bound that: the reachability - // check skips sending altogether while the platform reports no network, and the backoff - // spaces out attempts on platforms where reachability cannot be trusted. + // This transport opens a connection per envelope, and on Nintendo Switch every attempt made + // while offline raises the system's "connect to the internet" dialog. With logs or metrics + // enabled that is a prompt every few seconds, so sends are gated on network availability and + // spaced out by a backoff where availability cannot be trusted. private const double InitialBackoffSeconds = 1.0; private const double MaxBackoffSeconds = 60.0; @@ -110,9 +107,8 @@ internal IEnumerator SendEnvelopeAsync(Envelope envelope) } /// - /// Whether the platform reports a usable network. Prefers the platform's own probe where one - /// is available, because Unity's reachability cannot always be trusted - on Nintendo Switch it - /// reports the console as reachable while it is offline. + /// Whether the platform reports a usable network. The platform's own probe wins over + /// reachability, which on Nintendo Switch claims the console is reachable while it is offline. /// private bool IsNetworkAvailable() { @@ -125,8 +121,8 @@ private bool IsNetworkAvailable() } /// - /// Whether it is worth opening a connection for this envelope. Envelopes dropped here are - /// recorded as discarded so client reports still account for them. + /// Whether it is worth opening a connection for this envelope. Drops are recorded so client + /// reports still account for them. /// private bool CanAttemptSend(Envelope envelope) { @@ -162,8 +158,8 @@ private void OnConnectionError(UnityWebRequest www, Envelope envelope) InitialBackoffSeconds * Math.Pow(2, _consecutiveConnectionErrors - 1)); _retryAfterRealtime = Time.realtimeSinceStartupAsDouble + backoff; - // Reachability is logged here because a platform reporting itself reachable while the - // connection fails is exactly the case the backoff exists to cover. + // Reachability is logged because a platform claiming to be reachable while the connection + // fails is the case the backoff exists to cover. var backoffDetail = $"{backoff:F1}s (consecutive failure #{_consecutiveConnectionErrors})"; _options.LogWarning( "Failed to send request: {0}. Reachability reported as {1}. Backing off for {2}.", diff --git a/test/Sentry.Unity.Tests/UnityWebRequestTransportTests.cs b/test/Sentry.Unity.Tests/UnityWebRequestTransportTests.cs index 3179edf88..a25e9c8d8 100644 --- a/test/Sentry.Unity.Tests/UnityWebRequestTransportTests.cs +++ b/test/Sentry.Unity.Tests/UnityWebRequestTransportTests.cs @@ -16,9 +16,8 @@ public class UnityWebRequestTransportTests private static Envelope CreateEnvelope() => Envelope.FromEvent(new SentryEvent()); /// - /// The coroutine has to bail before its first yield. Anything else means a - /// got created, which is what raises the - /// system connection dialog on platforms like the Nintendo Switch. + /// A coroutine that yields has created a , + /// which is what raises the system connection dialog on platforms like the Nintendo Switch. /// [Test] public void SendEnvelopeAsync_NetworkNotReachable_DoesNotOpenAConnection() @@ -46,9 +45,8 @@ public void SendEnvelopeAsync_NetworkReachable_AttemptsToSend() } /// - /// The platform probe has the final say. On Nintendo Switch reachability reports the console as - /// reachable while it is offline, and attempting the send is what raises the system dialog - so - /// a probe saying "no network" has to win over reachability saying otherwise. + /// The probe has the final say: on Nintendo Switch reachability claims the console is reachable + /// while it is offline, which is the state the dialog gets raised in. /// [Test] public void SendEnvelopeAsync_ProbeReportsNoNetwork_OverridesReachability() From 33946261af54d9d8acae6deec8f35fa1f1147de6 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 4 Sep 2026 16:11:43 +0200 Subject: [PATCH 4/6] simplified backing off mechanism --- src/Sentry.Unity.Native/SentryNativeSwitch.cs | 1 - src/Sentry.Unity/SentryUnityOptions.cs | 7 -- src/Sentry.Unity/UnityWebRequestTransport.cs | 90 +++---------------- 3 files changed, 14 insertions(+), 84 deletions(-) diff --git a/src/Sentry.Unity.Native/SentryNativeSwitch.cs b/src/Sentry.Unity.Native/SentryNativeSwitch.cs index 4f139a2c9..b01a93ce5 100644 --- a/src/Sentry.Unity.Native/SentryNativeSwitch.cs +++ b/src/Sentry.Unity.Native/SentryNativeSwitch.cs @@ -84,7 +84,6 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf return; } - // The probe only needs the native library linked, so it survives a failure of the setup below. Logger?.LogDebug("Using the native SDK to determine network availability."); options.NetworkAvailabilityProbe = IsNetworkAvailable; diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 4dcb300e0..749339cba 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -493,13 +493,6 @@ internal string? DefaultUserId /// internal Action? NativeSupportCloseCallback { get; set; } = null; - /// - /// Reports whether the network is usable on platforms where - /// cannot be trusted. - /// - /// - /// Must not block - the transport calls this on the main thread before each send. - /// internal Func? NetworkAvailabilityProbe { get; set; } = null; internal List SdkIntegrationNames { get; set; } = new(); diff --git a/src/Sentry.Unity/UnityWebRequestTransport.cs b/src/Sentry.Unity/UnityWebRequestTransport.cs index f5aeeeee2..aabeb3ad2 100644 --- a/src/Sentry.Unity/UnityWebRequestTransport.cs +++ b/src/Sentry.Unity/UnityWebRequestTransport.cs @@ -54,17 +54,10 @@ private IEnumerator SendAndTrack(Envelope envelope) internal class UnityWebRequestTransport : HttpTransportBase { private readonly SentryUnityOptions _options; - - // This transport opens a connection per envelope, and on Nintendo Switch every attempt made - // while offline raises the system's "connect to the internet" dialog. With logs or metrics - // enabled that is a prompt every few seconds, so sends are gated on network availability and - // spaced out by a backoff where availability cannot be trusted. - private const double InitialBackoffSeconds = 1.0; - private const double MaxBackoffSeconds = 60.0; - private readonly IApplication _application; - private int _consecutiveConnectionErrors; + private const double MaxBackoffSeconds = 300.0; + private double _backoffSeconds; private double _retryAfterRealtime; public UnityWebRequestTransport(SentryUnityOptions options, IApplication? application = null) @@ -77,8 +70,12 @@ public UnityWebRequestTransport(SentryUnityOptions options, IApplication? applic // adapted HttpTransport.SendEnvelopeAsync() internal IEnumerator SendEnvelopeAsync(Envelope envelope) { - if (!CanAttemptSend(envelope)) + // This transport opens a connection per envelope, causing a prompt to appear on platforms + // like Nintendo Switch while offline. + if (!IsNetworkAvailable() || Time.realtimeSinceStartupAsDouble < _retryAfterRealtime) { + _options.LogDebug("Network unavailable or backing off. Dropping envelope instead of attempting to send."); + _options.ClientReportRecorder.RecordDiscardedEvents(DiscardReason.NetworkError, envelope); yield break; } @@ -92,11 +89,16 @@ internal IEnumerator SendEnvelopeAsync(Envelope envelope) if (www.result == UnityWebRequest.Result.ConnectionError) { - OnConnectionError(www, processedEnvelope); + _backoffSeconds = Math.Min(MaxBackoffSeconds, _backoffSeconds > 0.0 ? _backoffSeconds * 2 : 1.0); + _retryAfterRealtime = Time.realtimeSinceStartupAsDouble + _backoffSeconds; + + _options.LogWarning("Failed to send request: {0}. Backing off for {1:F1}s.", www.error, _backoffSeconds); + _options.ClientReportRecorder.RecordDiscardedEvents(DiscardReason.NetworkError, processedEnvelope); yield break; } - OnConnectionSucceeded(); + _backoffSeconds = 0.0; + _retryAfterRealtime = 0.0; var response = GetResponse(www); if (response is not null) @@ -106,10 +108,6 @@ internal IEnumerator SendEnvelopeAsync(Envelope envelope) } } - /// - /// Whether the platform reports a usable network. The platform's own probe wins over - /// reachability, which on Nintendo Switch claims the console is reachable while it is offline. - /// private bool IsNetworkAvailable() { if (_options.NetworkAvailabilityProbe is { } probe) @@ -120,66 +118,6 @@ private bool IsNetworkAvailable() return _application.InternetReachability != NetworkReachability.NotReachable; } - /// - /// Whether it is worth opening a connection for this envelope. Drops are recorded so client - /// reports still account for them. - /// - private bool CanAttemptSend(Envelope envelope) - { - if (!IsNetworkAvailable()) - { - _options.LogDebug("No network available. Dropping envelope instead of attempting to send."); - _options.ClientReportRecorder.RecordDiscardedEvents(DiscardReason.NetworkError, envelope); - return false; - } - - if (_consecutiveConnectionErrors > 0) - { - var remaining = _retryAfterRealtime - Time.realtimeSinceStartupAsDouble; - if (remaining > 0.0) - { - _options.LogDebug( - "Backing off after {0} failed connection attempt(s). Dropping envelope, retrying in {1:F1}s.", - _consecutiveConnectionErrors, remaining); - _options.ClientReportRecorder.RecordDiscardedEvents(DiscardReason.NetworkError, envelope); - return false; - } - } - - return true; - } - - private void OnConnectionError(UnityWebRequest www, Envelope envelope) - { - _consecutiveConnectionErrors++; - - var backoff = Math.Min( - MaxBackoffSeconds, - InitialBackoffSeconds * Math.Pow(2, _consecutiveConnectionErrors - 1)); - _retryAfterRealtime = Time.realtimeSinceStartupAsDouble + backoff; - - // Reachability is logged because a platform claiming to be reachable while the connection - // fails is the case the backoff exists to cover. - var backoffDetail = $"{backoff:F1}s (consecutive failure #{_consecutiveConnectionErrors})"; - _options.LogWarning( - "Failed to send request: {0}. Reachability reported as {1}. Backing off for {2}.", - www.error, _application.InternetReachability, backoffDetail); - - _options.ClientReportRecorder.RecordDiscardedEvents(DiscardReason.NetworkError, envelope); - } - - private void OnConnectionSucceeded() - { - if (_consecutiveConnectionErrors == 0) - { - return; - } - - _options.LogDebug("Connection restored after {0} failed attempt(s).", _consecutiveConnectionErrors); - _consecutiveConnectionErrors = 0; - _retryAfterRealtime = 0.0; - } - private UnityWebRequest CreateWebRequest(HttpRequestMessage message) { using var contentStream = ReadStreamFromHttpContent(message.Content); From a1ac88e5fdce0a2cd97ff037225686e8e2af51be Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 4 Sep 2026 16:15:43 +0200 Subject: [PATCH 5/6] updated changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f79850d5..a13739050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ ### Fixes - IL2CPP line numbers now work on Android x86/x86_64 builds. il2cpp fails to report the image UUID there, so the SDK falls back to looking the debug image up by name ([#2817](https://github.com/getsentry/sentry-unity/pull/2817)) -- The `UnityWebRequestTransport` no longer opens a connection while the platform reports no network, and backs off exponentially (1s up to 60s) after a connection error instead of retrying on every envelope. On Nintendo Switch each send attempt made while offline raises the system's "connect to the internet" dialog, so a game with logs or metrics enabled produced a prompt every few seconds ([#2833](https://github.com/getsentry/sentry-unity/pull/2833)) -- On Nintendo Switch the SDK now asks the native SDK whether the network is usable before sending, instead of relying on `Application.internetReachability`, which reports the console as reachable while it is offline. Every send attempted in that state raised the system's "connect to the internet" dialog. Requires a sentry-switch build exposing `sentry_switch_utils_is_network_available()` ([#2833](https://github.com/getsentry/sentry-unity/pull/2833)) +- When targeting WebGL or Nintendo Switch without native support, `UnityWebRequestTransport` no longer opens a connection while the platform reports no network, and backs off exponentially (1s up to 300s) after a connection error instead of retrying on every envelope. ([#2833](https://github.com/getsentry/sentry-unity/pull/2833)) +- When targeting Nintendo Switch, the SDK now utilizes sentry-switch to poll the network status before sending. ([#2833](https://github.com/getsentry/sentry-unity/pull/2833)) ### Dependencies From 67d2544532cd617f288768602226118aabec2d3c Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 4 Sep 2026 16:53:15 +0200 Subject: [PATCH 6/6] restore client report --- src/Sentry.Unity/UnityWebRequestTransport.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/Sentry.Unity/UnityWebRequestTransport.cs b/src/Sentry.Unity/UnityWebRequestTransport.cs index aabeb3ad2..1e92d5d46 100644 --- a/src/Sentry.Unity/UnityWebRequestTransport.cs +++ b/src/Sentry.Unity/UnityWebRequestTransport.cs @@ -94,6 +94,7 @@ internal IEnumerator SendEnvelopeAsync(Envelope envelope) _options.LogWarning("Failed to send request: {0}. Backing off for {1:F1}s.", www.error, _backoffSeconds); _options.ClientReportRecorder.RecordDiscardedEvents(DiscardReason.NetworkError, processedEnvelope); + RestoreClientReports(processedEnvelope); yield break; } @@ -108,6 +109,20 @@ internal IEnumerator SendEnvelopeAsync(Envelope envelope) } } + // ProcessEnvelope drains the recorder into a client report item. Restore those counts instead of + // losing them along with the envelope we failed to send. + private void RestoreClientReports(Envelope envelope) + { + foreach (var item in envelope.Items) + { + if (item.TryGetType() == EnvelopeItem.TypeValueClientReport && + item.Payload is JsonSerializable { Source: ClientReport report }) + { + _options.ClientReportRecorder.Load(report); + } + } + } + private bool IsNetworkAvailable() { if (_options.NetworkAvailabilityProbe is { } probe)