Skip to content

Commit d5d4b66

Browse files
Envelopes that fail to send are now flushed when the transport recovers (#3438)
1 parent 767e7ab commit d5d4b66

File tree

7 files changed

+198
-12
lines changed

7 files changed

+198
-12
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Fixes
6+
7+
- Fixed envelopes getting stuck in processing when losing network connectivity ([#3438](https://github.com/getsentry/sentry-dotnet/pull/3438))
8+
59
### Features
610

711
- Client reports now include dropped spans ([#3463](https://github.com/getsentry/sentry-dotnet/pull/3463))

src/Sentry/Internal/Http/CachingTransport.cs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ namespace Sentry.Internal.Http;
1717
internal class CachingTransport : ITransport, IDisposable
1818
{
1919
private const string EnvelopeFileExt = "envelope";
20+
private const string ProcessingFolder = "__processing";
2021

2122
private readonly ITransport _innerTransport;
2223
private readonly SentryOptions _options;
@@ -77,7 +78,7 @@ private CachingTransport(ITransport innerTransport, SentryOptions options, bool
7778
options.TryGetProcessSpecificCacheDirectoryPath() ??
7879
throw new InvalidOperationException("Cache directory or DSN is not set.");
7980

80-
_processingDirectoryPath = Path.Combine(_isolatedCacheDirectoryPath, "__processing");
81+
_processingDirectoryPath = Path.Combine(_isolatedCacheDirectoryPath, ProcessingFolder);
8182
}
8283

8384
private void Initialize(bool startWorker)
@@ -271,6 +272,13 @@ private async Task ProcessCacheAsync(CancellationToken cancellation)
271272
// Signal that we can start waiting for _options.InitCacheFlushTimeout
272273
_preInitCacheResetEvent?.Set();
273274

275+
// Make sure no files got stuck in the processing directory
276+
// See https://github.com/getsentry/sentry-dotnet/pull/3438#discussion_r1672524426
277+
if (_options.NetworkStatusListener is { Online: false } listener)
278+
{
279+
MoveUnprocessedFilesBackToCache();
280+
}
281+
274282
// Process the cache
275283
_options.LogDebug("Flushing cached envelopes.");
276284
while (await TryPrepareNextCacheFileAsync(cancellation).ConfigureAwait(false) is { } file)
@@ -288,6 +296,13 @@ private async Task ProcessCacheAsync(CancellationToken cancellation)
288296
}
289297
}
290298

299+
private static bool IsNetworkError(Exception exception) =>
300+
exception switch
301+
{
302+
HttpRequestException or WebException or IOException or SocketException => true,
303+
_ => false
304+
};
305+
291306
private async Task InnerProcessCacheAsync(string file, CancellationToken cancellation)
292307
{
293308
if (_options.NetworkStatusListener is { Online: false } listener)
@@ -327,9 +342,12 @@ private async Task InnerProcessCacheAsync(string file, CancellationToken cancell
327342
// Let the worker catch, log, wait a bit and retry.
328343
throw;
329344
}
330-
catch (Exception ex) when (ex is HttpRequestException or WebException or SocketException
331-
or IOException)
345+
catch (Exception ex) when (IsNetworkError(ex))
332346
{
347+
if (_options.NetworkStatusListener is PollingNetworkStatusListener pollingListener)
348+
{
349+
pollingListener.Online = false;
350+
}
333351
_options.LogError(ex, "Failed to send cached envelope: {0}, retrying after a delay.", file);
334352
// Let the worker catch, log, wait a bit and retry.
335353
throw;
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using Sentry.Extensibility;
2+
3+
namespace Sentry.Internal;
4+
5+
internal class PollingNetworkStatusListener : INetworkStatusListener
6+
{
7+
private readonly SentryOptions? _options;
8+
private readonly IPing? _testPing;
9+
internal int _delayInMilliseconds;
10+
private readonly int _maxDelayInMilliseconds;
11+
private readonly Func<int, int> _backoffFunction;
12+
13+
public PollingNetworkStatusListener(SentryOptions options, int initialDelayInMilliseconds = 500,
14+
int maxDelayInMilliseconds = 32_000, Func<int, int>? backoffFunction = null)
15+
{
16+
_options = options;
17+
_delayInMilliseconds = initialDelayInMilliseconds;
18+
_maxDelayInMilliseconds = maxDelayInMilliseconds;
19+
_backoffFunction = backoffFunction ?? (x => x * 2);
20+
}
21+
22+
/// <summary>
23+
/// Overload for testing
24+
/// </summary>
25+
internal PollingNetworkStatusListener(IPing testPing, int initialDelayInMilliseconds = 500,
26+
int maxDelayInMilliseconds = 32_000, Func<int, int>? backoffFunction = null)
27+
{
28+
_testPing = testPing;
29+
_delayInMilliseconds = initialDelayInMilliseconds;
30+
_maxDelayInMilliseconds = maxDelayInMilliseconds;
31+
_backoffFunction = backoffFunction ?? (x => x * 2);
32+
}
33+
34+
private Lazy<IPing> LazyPing => new(() =>
35+
{
36+
if (_testPing != null)
37+
{
38+
return _testPing;
39+
}
40+
// If not running unit tests then _options will not be null and SDK init would fail without a Dsn
41+
var uri = new Uri(_options!.Dsn!);
42+
return new TcpPing(uri.DnsSafeHost, uri.Port);
43+
});
44+
private IPing Ping => LazyPing.Value;
45+
46+
private volatile bool _online = true;
47+
public bool Online
48+
{
49+
get => _online;
50+
set => _online = value;
51+
}
52+
53+
public async Task WaitForNetworkOnlineAsync(CancellationToken cancellationToken = default)
54+
{
55+
while (!cancellationToken.IsCancellationRequested)
56+
{
57+
await Task.Delay(_delayInMilliseconds, cancellationToken).ConfigureAwait(false);
58+
var checkResult = await Ping.IsAvailableAsync().ConfigureAwait(false);
59+
if (checkResult)
60+
{
61+
Online = true;
62+
return;
63+
}
64+
if (_delayInMilliseconds < _maxDelayInMilliseconds)
65+
{
66+
_delayInMilliseconds = _backoffFunction(_delayInMilliseconds);
67+
}
68+
}
69+
}
70+
}

src/Sentry/Internal/TcpPing.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
namespace Sentry.Internal;
2+
3+
internal interface IPing
4+
{
5+
Task<bool> IsAvailableAsync();
6+
}
7+
8+
internal class TcpPing(string hostToCheck, int portToCheck = 443) : IPing
9+
{
10+
private readonly Ping _ping = new();
11+
12+
public async Task<bool> IsAvailableAsync()
13+
{
14+
try
15+
{
16+
using var tcpClient = new TcpClient();
17+
await tcpClient.ConnectAsync(hostToCheck, portToCheck).ConfigureAwait(false);
18+
return true;
19+
}
20+
catch (Exception)
21+
{
22+
return false;
23+
}
24+
}
25+
}

src/Sentry/SentryOptions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1297,6 +1297,10 @@ public SentryOptions()
12971297
UriComponents.SchemeAndServer,
12981298
UriFormat.Unescaped)
12991299
);
1300+
1301+
#if PLATFORM_NEUTRAL
1302+
NetworkStatusListener = new PollingNetworkStatusListener(this);
1303+
#endif
13001304
}
13011305

13021306
/// <summary>

test/Sentry.Tests/Internals/Http/CachingTransportTests.cs

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -545,14 +545,17 @@ await Assert.ThrowsAnyAsync<Exception>(async () =>
545545
[MemberData(nameof(NetworkTestData))]
546546
public async Task TestNetworkException(Exception exception)
547547
{
548-
// Arrange
548+
// Arrange - network unavailable
549549
using var cacheDirectory = new TempDirectory(_fileSystem);
550+
var pingHost = Substitute.For<IPing>();
551+
pingHost.IsAvailableAsync().Returns(Task.FromResult(true));
550552
var options = new SentryOptions
551553
{
552554
Dsn = ValidDsn,
553555
DiagnosticLogger = _logger,
554556
Debug = true,
555557
CacheDirectoryPath = cacheDirectory.Path,
558+
NetworkStatusListener = new PollingNetworkStatusListener(pingHost),
556559
FileSystem = _fileSystem
557560
};
558561

@@ -577,16 +580,25 @@ public async Task TestNetworkException(Exception exception)
577580
{
578581
receivedException = he;
579582
}
580-
finally
581-
{
582-
// (transport stops failing)
583-
innerTransport.ClearReceivedCalls();
584-
await transport.FlushAsync();
585-
}
586583

587584
// Assert
588-
Assert.Equal(exception, receivedException);
589-
Assert.True(_fileSystem.EnumerateFiles(cacheDirectory.Path, "*", SearchOption.AllDirectories).Any());
585+
receivedException.Should().Be(exception);
586+
var files = _fileSystem.EnumerateFiles(cacheDirectory.Path, "*", SearchOption.AllDirectories).ToArray();
587+
files.Should().NotBeEmpty();
588+
589+
// Arrange - network recovery
590+
innerTransport.ClearReceivedCalls();
591+
innerTransport
592+
.SendEnvelopeAsync(Arg.Any<Envelope>(), Arg.Any<CancellationToken>())
593+
.Returns(_ => Task.CompletedTask);
594+
595+
// Act
596+
await transport.FlushAsync();
597+
598+
// Assert
599+
receivedException.Should().Be(exception);
600+
files = _fileSystem.EnumerateFiles(cacheDirectory.Path, "*", SearchOption.AllDirectories).ToArray();
601+
files.Should().NotContain(file => file.Contains("__processing", StringComparison.OrdinalIgnoreCase));
590602
}
591603

592604
[Fact]
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
namespace Sentry.Tests.Internals;
2+
3+
public class PollingNetworkStatusListenerTest
4+
{
5+
[Fact]
6+
public async Task HostAvailable_CheckOnlyRunsOnce()
7+
{
8+
// Arrange
9+
var initialDelay = 100;
10+
var pingHost = Substitute.For<IPing>();
11+
pingHost
12+
.IsAvailableAsync()
13+
.Returns(Task.FromResult(true));
14+
15+
var pollingListener = new PollingNetworkStatusListener(pingHost, initialDelay);
16+
pollingListener.Online = false;
17+
18+
// Act
19+
var waitForNetwork = pollingListener.WaitForNetworkOnlineAsync();
20+
var timeout = Task.Delay(1000);
21+
var completedTask = await Task.WhenAny(waitForNetwork, timeout);
22+
23+
// Assert
24+
completedTask.Should().Be(waitForNetwork);
25+
pollingListener.Online.Should().Be(true);
26+
await pingHost.Received(1).IsAvailableAsync();
27+
}
28+
29+
[Fact]
30+
public async Task HostUnavailable_ShouldIncreaseDelay()
31+
{
32+
// Arrange
33+
var initialDelay = 100; // set initial delay to ease the testing
34+
var pingHost = Substitute.For<IPing>();
35+
pingHost
36+
.IsAvailableAsync()
37+
.Returns(Task.FromResult(false));
38+
39+
var pollingListener = new PollingNetworkStatusListener(pingHost, initialDelay);
40+
pollingListener.Online = false;
41+
42+
// Act
43+
var waitForNetwork = pollingListener.WaitForNetworkOnlineAsync();
44+
var timeout = Task.Delay(2000);
45+
var completedTask = await Task.WhenAny(waitForNetwork, timeout);
46+
47+
// Assert
48+
completedTask.Should().Be(timeout);
49+
pollingListener.Online.Should().Be(false);
50+
await pingHost.Received().IsAvailableAsync();
51+
pollingListener._delayInMilliseconds.Should().BeGreaterThan(initialDelay);
52+
}
53+
}

0 commit comments

Comments
 (0)