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
42 changes: 34 additions & 8 deletions Analytics-CSharp/Segment/Analytics/Retry/RetryStateMachine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public RetryState HandleResponse(RetryState state, ResponseInfo response)
{
if (IsLegacyMode)
{
if (response.StatusCode >= 200 && response.StatusCode <= 299)
if (response.StatusCode >= 200 && response.StatusCode < 400)
return state.RemoveBatch(response.BatchFile);
if (response.StatusCode == 429 || (response.StatusCode >= 500 && response.StatusCode <= 599))
return state; // Keep
Expand All @@ -31,7 +31,7 @@ public RetryState HandleResponse(RetryState state, ResponseInfo response)

long currentTime = response.CurrentTime;

if (response.StatusCode >= 200 && response.StatusCode <= 299)
if (response.StatusCode >= 200 && response.StatusCode < 400)
{
return state.With(
pipelineState: PipelineState.Ready,
Expand All @@ -41,15 +41,25 @@ public RetryState HandleResponse(RetryState state, ResponseInfo response)
);
}

// Any retryable status with Retry-After → rate-limit path
if (response.RetryAfterSeconds.HasValue && response.RetryAfterSeconds.Value > 0)
{
RetryBehavior behavior = response.StatusCode == 429
? RetryBehavior.Retry // 429 is always retryable
: ResolveStatusCodeBehavior(response.StatusCode);
if (behavior == RetryBehavior.Retry && _config.RateLimitConfig.Enabled)
return HandleRateLimitResponse(state, response, currentTime);
}

if (response.StatusCode == 429)
{
if (_config.RateLimitConfig.Enabled)
return HandleRateLimitResponse(state, response, currentTime);
return state.RemoveBatch(response.BatchFile);
}

RetryBehavior behavior = ResolveStatusCodeBehavior(response.StatusCode);
if (behavior == RetryBehavior.Retry && _config.BackoffConfig.Enabled)
RetryBehavior statusBehavior = ResolveStatusCodeBehavior(response.StatusCode);
if (statusBehavior == RetryBehavior.Retry && _config.BackoffConfig.Enabled)
return HandleRetryableError(state, response, currentTime);

return state.RemoveBatch(response.BatchFile);
Expand Down Expand Up @@ -131,20 +141,36 @@ public int GetRetryCount(RetryState state, string batchFile)
return Math.Max(batchRetryCount, state.GlobalRetryCount);
}

public bool ShouldDeleteBatch(int statusCode)
public bool ShouldDeleteBatch(int statusCode) => ShouldDeleteBatch(statusCode, null);

/// <summary>
/// Whether the batch file should be removed. <paramref name="retryAfterSeconds"/> must be
/// the same value handed to <see cref="HandleResponse"/>, so that the two agree on whether
/// this response took the rate-limit path.
/// </summary>
public bool ShouldDeleteBatch(int statusCode, int? retryAfterSeconds)
{
if (IsLegacyMode)
return statusCode >= 400 && statusCode <= 499 && statusCode != 429;

if (statusCode >= 200 && statusCode <= 299)
// Spec item 1: 2xx and 3xx are success.
if (statusCode >= 200 && statusCode < 400)
return true;

if (statusCode == 429)
return !_config.RateLimitConfig.Enabled;

RetryBehavior behavior = ResolveStatusCodeBehavior(statusCode);
if (behavior == RetryBehavior.Retry && !_config.BackoffConfig.Enabled)
return true;
if (behavior == RetryBehavior.Retry)
{
// A usable Retry-After sends this response down the rate-limit path, which has
// just scheduled the retry — keep the batch that retry will re-upload.
if (retryAfterSeconds.HasValue && retryAfterSeconds.Value > 0 && _config.RateLimitConfig.Enabled)
return false;

// Otherwise only backoff can retry it; with backoff off, nothing will.
return !_config.BackoffConfig.Enabled;
}

return behavior == RetryBehavior.Drop;
}
Expand Down
8 changes: 2 additions & 6 deletions Analytics-CSharp/Segment/Analytics/Utilities/EventPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,11 +209,7 @@ await Scope.WithContext(_analytics.FileIODispatcher, () =>
HTTPClient.Response response = await _httpClient.UploadWithResponse(data, retryCount);
statusCode = response.StatusCode;

if (!string.IsNullOrEmpty(response.RetryAfterHeader)
&& int.TryParse(response.RetryAfterHeader.Trim(), out int parsedRetryAfter))
{
retryAfterSeconds = parsedRetryAfter;
}
retryAfterSeconds = RetryAfterParser.Parse(response.RetryAfterHeader);

if (response.IsSuccessStatusCode)
{
Expand All @@ -223,7 +219,7 @@ await Scope.WithContext(_analytics.FileIODispatcher, () =>
else
{
Analytics.Logger.Log(LogLevel.Error, message: "Error " + statusCode + " uploading " + url);
shouldCleanup = _retryStateMachine.ShouldDeleteBatch(statusCode);
shouldCleanup = _retryStateMachine.ShouldDeleteBatch(statusCode, retryAfterSeconds);
if (shouldCleanup)
{
_analytics.ReportInternalError(AnalyticsErrorType.NetworkServerRejected,
Expand Down
3 changes: 2 additions & 1 deletion Analytics-CSharp/Segment/Analytics/Utilities/HTTPClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ public class Response
/// <summary>
/// A convenient method to check if the http request is successful
/// </summary>
public bool IsSuccessStatusCode => StatusCode >= 200 && StatusCode < 300;
// Spec item 1: 2xx and 3xx are success.
public bool IsSuccessStatusCode => StatusCode >= 200 && StatusCode < 400;
}
}

Expand Down
38 changes: 38 additions & 0 deletions Analytics-CSharp/Segment/Analytics/Utilities/RetryAfterParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System;
using System.Globalization;

namespace Segment.Analytics.Utilities
{
internal static class RetryAfterParser
{
/// <summary>
/// Parses a Retry-After header value. Supports both integer seconds and HTTP-date (RFC 1123) format.
/// Returns the number of seconds to wait, or null if the header is empty/unparseable/in the past.
/// </summary>
internal static int? Parse(string headerValue, DateTimeOffset? now = null)
{
if (string.IsNullOrEmpty(headerValue))
return null;

string trimmed = headerValue.Trim();

if (int.TryParse(trimmed, out int parsedInt))
{
return parsedInt;
}

if (DateTimeOffset.TryParseExact(trimmed,
new[] { "r", "ddd, dd MMM yyyy HH:mm:ss 'GMT'" },
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal,
out DateTimeOffset targetDate))
{
DateTimeOffset reference = now ?? DateTimeOffset.UtcNow;
int seconds = (int)(targetDate - reference).TotalSeconds;
return seconds > 0 ? seconds : (int?)null;
}

return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -234,11 +234,7 @@ await Scope.WithContext(_analytics.FileIODispatcher, () =>
HTTPClient.Response response = await _httpClient.UploadWithResponse(data, retryCount);
statusCode = response.StatusCode;

if (!string.IsNullOrEmpty(response.RetryAfterHeader)
&& int.TryParse(response.RetryAfterHeader.Trim(), out int parsedRetryAfter))
{
retryAfterSeconds = parsedRetryAfter;
}
retryAfterSeconds = RetryAfterParser.Parse(response.RetryAfterHeader);

if (response.IsSuccessStatusCode)
{
Expand All @@ -248,7 +244,7 @@ await Scope.WithContext(_analytics.FileIODispatcher, () =>
else
{
Analytics.Logger.Log(LogLevel.Error, message: "Error " + statusCode + " uploading " + url);
shouldCleanup = _retryStateMachine.ShouldDeleteBatch(statusCode);
shouldCleanup = _retryStateMachine.ShouldDeleteBatch(statusCode, retryAfterSeconds);
if (shouldCleanup)
{
_analytics.ReportInternalError(AnalyticsErrorType.NetworkServerRejected,
Expand Down
74 changes: 74 additions & 0 deletions Tests/Retry/RetryAfterDeleteBatchTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using Segment.Analytics.Retry;
using Xunit;

namespace Tests.Retry
{
/// <summary>
/// ShouldDeleteBatch must agree with HandleResponse about whether a response took the
/// rate-limit path. A retryable status carrying Retry-After schedules a retry, so its
/// batch must be kept; without Retry-After only backoff can retry it.
/// </summary>
public class RetryAfterDeleteBatchTest
{
private static RetryStateMachine RateLimitOnlyMachine() =>
new RetryStateMachine(new RetryConfig(
new RateLimitConfig(enabled: true),
new BackoffConfig(enabled: false)));

[Theory]
[InlineData(503)]
[InlineData(529)]
[InlineData(408)]
[InlineData(410)]
public void RetryableStatus_WithRetryAfter_IsKept(int status)
{
Assert.False(RateLimitOnlyMachine().ShouldDeleteBatch(status, 30));
}

[Theory]
[InlineData(503)]
[InlineData(529)]
public void RetryableStatus_WithoutRetryAfter_AndBackoffDisabled_IsDeleted(int status)
{
// Nothing would retry it, so holding the file would leak storage.
Assert.True(RateLimitOnlyMachine().ShouldDeleteBatch(status, null));
}

[Fact]
public void RetryAfterZero_DoesNotCountAsRateLimited()
{
Assert.True(RateLimitOnlyMachine().ShouldDeleteBatch(503, 0));
}

[Fact]
public void RetryAfter_RateLimitsPipelineAndKeepsBatch()
{
var machine = RateLimitOnlyMachine();
var response = new ResponseInfo(503, retryAfterSeconds: 30, batchFile: "b.json", currentTime: 1000);

RetryState state = machine.HandleResponse(new RetryState(), response);

Assert.Equal(PipelineState.RateLimited, state.PipelineState);
Assert.Equal(31000, state.WaitUntilTime);
Assert.False(machine.ShouldDeleteBatch(503, 30));
}

[Theory]
[InlineData(200)]
[InlineData(201)]
[InlineData(301)]
[InlineData(304)]
public void SuccessStatuses_AreDeleted(int status)
{
// Spec item 1: 2xx and 3xx are success, so the batch is done with.
Assert.True(RateLimitOnlyMachine().ShouldDeleteBatch(status, null));
}

[Fact]
public void NonRetryableStatus_IsDeletedEvenWithRetryAfter()
{
Assert.True(RateLimitOnlyMachine().ShouldDeleteBatch(400, 30));
Assert.True(RateLimitOnlyMachine().ShouldDeleteBatch(501, 30));
}
}
}
81 changes: 81 additions & 0 deletions Tests/Retry/RetryAfterParserTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System;
using Segment.Analytics.Utilities;
using Xunit;

namespace Tests.Retry
{
public class RetryAfterParserTest
{
[Fact]
public void Parse_IntegerSeconds_ReturnsParsedValue()
{
Assert.Equal(60, RetryAfterParser.Parse("60"));
}

[Fact]
public void Parse_IntegerWithWhitespace_ReturnsParsedValue()
{
Assert.Equal(120, RetryAfterParser.Parse(" 120 "));
}

[Fact]
public void Parse_Null_ReturnsNull()
{
Assert.Null(RetryAfterParser.Parse(null));
}

[Fact]
public void Parse_Empty_ReturnsNull()
{
Assert.Null(RetryAfterParser.Parse(""));
}

[Fact]
public void Parse_HttpDate_InFuture_ReturnsSeconds()
{
var now = new DateTimeOffset(2026, 6, 16, 12, 0, 0, TimeSpan.Zero);
// 2 seconds in the future
string httpDate = "Tue, 16 Jun 2026 12:00:02 GMT";

int? result = RetryAfterParser.Parse(httpDate, now);

Assert.Equal(2, result);
}

[Fact]
public void Parse_HttpDate_InPast_ReturnsNull()
{
var now = new DateTimeOffset(2026, 6, 16, 12, 0, 0, TimeSpan.Zero);
// 10 seconds in the past
string httpDate = "Tue, 16 Jun 2026 11:59:50 GMT";

int? result = RetryAfterParser.Parse(httpDate, now);

Assert.Null(result);
}

[Fact]
public void Parse_HttpDate_Rfc1123Format_ParsesCorrectly()
{
var now = new DateTimeOffset(2026, 6, 16, 10, 0, 0, TimeSpan.Zero);
// 300 seconds (5 minutes) in the future
string httpDate = "Tue, 16 Jun 2026 10:05:00 GMT";

int? result = RetryAfterParser.Parse(httpDate, now);

Assert.Equal(300, result);
}

[Fact]
public void Parse_InvalidString_ReturnsNull()
{
Assert.Null(RetryAfterParser.Parse("not-a-date-or-number"));
}

[Fact]
public void Parse_Zero_ReturnsZero()
{
Assert.Equal(0, RetryAfterParser.Parse("0"));
}
}
}
Loading