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
16 changes: 16 additions & 0 deletions Analytics-CSharp/Segment/Analytics/Configuration.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using Segment.Analytics.Policies;
using Segment.Analytics.Retry;
using Segment.Analytics.Utilities;
using Segment.Concurrent;
using Segment.Serialization;
Expand Down Expand Up @@ -47,6 +48,21 @@ private set

public IEventPipelineProvider EventPipelineProvider { get; }

/// <summary>
/// HTTP retry configuration for rate limiting and exponential backoff. Defaults to
/// <c>null</c>. Set it before constructing <c>Analytics</c>, e.g.
/// <c>new Configuration("writeKey") { HttpConfig = new HttpConfig(...) }</c>.
/// Mirrors analytics-kotlin's mutable <c>Configuration.httpConfig</c>.
/// <para>
/// This sets the pipeline's starting configuration only. CDN settings take precedence:
/// any settings payload carrying an <c>httpConfig</c> key replaces this value, and a CDN
/// payload is treated as enabling a subsystem unless it says <c>"enabled": "false"</c>.
/// A payload with no <c>httpConfig</c> key leaves this value in effect. This matches the
/// behaviour of analytics-kotlin and analytics-swift.
/// </para>
/// </summary>
public HttpConfig HttpConfig { get; set; }

/// <summary>
/// Configuration that analytics can use
/// </summary>
Expand Down
17 changes: 12 additions & 5 deletions Analytics-CSharp/Segment/Analytics/Retry/RetryConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

namespace Segment.Analytics.Retry
{
internal class RateLimitConfig
public class RateLimitConfig
{
public bool Enabled { get; }
public int MaxRetryCount { get; }
Expand All @@ -23,7 +23,7 @@ public RateLimitConfig(bool enabled = false, int maxRetryCount = 100, int maxRet
);
}

internal class BackoffConfig
public class BackoffConfig
{
public bool Enabled { get; }
public int MaxRetryCount { get; }
Expand Down Expand Up @@ -57,7 +57,10 @@ public BackoffConfig(
Default4xxBehavior = default4xxBehavior;
Default5xxBehavior = default5xxBehavior;
UnknownCodeBehavior = unknownCodeBehavior;
StatusCodeOverrides = statusCodeOverrides ?? DefaultStatusCodeOverrides;
// Copied because the property is public: sharing the static default would let
// one caller's mutation corrupt every BackoffConfig built afterwards.
StatusCodeOverrides = new Dictionary<int, RetryBehavior>(
statusCodeOverrides ?? DefaultStatusCodeOverrides);
}

public BackoffConfig Validated() => new BackoffConfig(
Expand Down Expand Up @@ -93,7 +96,11 @@ private static Dictionary<int, RetryBehavior> ValidateOverrides(
{ 429, RetryBehavior.Retry },
{ 460, RetryBehavior.Retry },
{ 501, RetryBehavior.Drop },
{ 505, RetryBehavior.Drop }
{ 505, RetryBehavior.Drop },
// 511 is only retryable for an SDK that can re-authenticate via OAuth.
// This one cannot, so retrying would spend the budget on a request that
// can never succeed.
{ 511, RetryBehavior.Drop }
};
}

Expand All @@ -109,7 +116,7 @@ public RetryConfig(RateLimitConfig rateLimitConfig = null, BackoffConfig backoff
}
}

internal class HttpConfig
public class HttpConfig
{
public RateLimitConfig RateLimitConfig { get; }
public BackoffConfig BackoffConfig { get; }
Expand Down
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
2 changes: 1 addition & 1 deletion Analytics-CSharp/Segment/Analytics/Retry/RetryTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ internal enum PipelineState
RateLimited
}

internal enum RetryBehavior
public enum RetryBehavior
{
Retry,
Drop
Expand Down
16 changes: 7 additions & 9 deletions Analytics-CSharp/Segment/Analytics/Utilities/EventPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public EventPipeline(
string apiHost = HTTPClient.DefaultAPIHost)
: this(analytics, logTag, apiKey, flushPolicies, apiHost, (HttpConfig)null) { }

internal EventPipeline(
public EventPipeline(
Analytics analytics,
string logTag,
string apiKey,
Expand All @@ -69,7 +69,9 @@ internal EventPipeline(
Running = false;

var retryConfig = httpConfig != null
? new RetryConfig(httpConfig.RateLimitConfig, httpConfig.BackoffConfig)
// User-supplied config arrives unclamped; the CDN path is already
// validated by HttpConfigParser.
? new RetryConfig(httpConfig.RateLimitConfig.Validated(), httpConfig.BackoffConfig.Validated())
: new RetryConfig();
_retryStateMachine = new RetryStateMachine(retryConfig);
_retryState = RetryStateStorage.LoadRetryState(_storage);
Expand All @@ -78,7 +80,7 @@ internal EventPipeline(
internal void UpdateHttpConfig(HttpConfig config)
{
var retryConfig = config != null
? new RetryConfig(config.RateLimitConfig, config.BackoffConfig)
? new RetryConfig(config.RateLimitConfig.Validated(), config.BackoffConfig.Validated())
: new RetryConfig();
_retryStateMachine = new RetryStateMachine(retryConfig);
}
Expand Down Expand Up @@ -209,11 +211,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 +221,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
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public IEventPipeline Create(Analytics analytics, string key)
return new EventPipeline(analytics, key,
analytics.Configuration.WriteKey,
analytics.Configuration.FlushPolicies,
analytics.Configuration.ApiHost);
analytics.Configuration.ApiHost,
analytics.Configuration.HttpConfig);
}
}
}
14 changes: 13 additions & 1 deletion Analytics-CSharp/Segment/Analytics/Utilities/HTTPClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using Segment.Analytics.Retry;
using Segment.Serialization;
Expand All @@ -25,6 +26,15 @@ public abstract class HTTPClient

private readonly string _apiKey;

/// <summary>
/// Value for the Authorization header: the write key as HTTP Basic credentials with an
/// empty password, matching the other Segment SDKs. TAPI authenticates and routes on this
/// header rather than parsing the payload, so custom <see cref="HTTPClient"/>
/// implementations should send it on upload requests.
/// </summary>
protected string BasicAuthorization =>
"Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes(_apiKey + ":"));

protected readonly string _apiHost;

protected readonly string _cdnHost;
Expand Down Expand Up @@ -188,7 +198,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 Expand Up @@ -242,6 +253,7 @@ public override async Task<Response> DoPost(string url, byte[] data, int retryCo

var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Add("Connection", "close");
request.Headers.Add("Authorization", BasicAuthorization);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain"));
if (retryCount > 0)
request.Headers.Add("X-Retry-Count", retryCount.ToString());
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 @@ -61,7 +61,7 @@ public SyncEventPipeline(
CancellationToken? flushCancellationToken = null)
: this(analytics, logTag, apiKey, flushPolicies, apiHost, flushTimeout, flushCancellationToken, null) { }

internal SyncEventPipeline(
public SyncEventPipeline(
Analytics analytics,
string logTag,
string apiKey,
Expand All @@ -86,7 +86,9 @@ internal SyncEventPipeline(
_flushCancellationToken = flushCancellationToken ?? CancellationToken.None;

var retryConfig = httpConfig != null
? new RetryConfig(httpConfig.RateLimitConfig, httpConfig.BackoffConfig)
// User-supplied config arrives unclamped; the CDN path is already
// validated by HttpConfigParser.
? new RetryConfig(httpConfig.RateLimitConfig.Validated(), httpConfig.BackoffConfig.Validated())
: new RetryConfig();
_retryStateMachine = new RetryStateMachine(retryConfig);
_retryState = RetryStateStorage.LoadRetryState(_storage);
Expand All @@ -95,7 +97,7 @@ internal SyncEventPipeline(
internal void UpdateHttpConfig(HttpConfig config)
{
var retryConfig = config != null
? new RetryConfig(config.RateLimitConfig, config.BackoffConfig)
? new RetryConfig(config.RateLimitConfig.Validated(), config.BackoffConfig.Validated())
: new RetryConfig();
_retryStateMachine = new RetryStateMachine(retryConfig);
}
Expand Down Expand Up @@ -234,11 +236,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 +246,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
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ public IEventPipeline Create(Analytics analytics, string key)
analytics.Configuration.FlushPolicies,
analytics.Configuration.ApiHost,
_flushTimeout,
_flushCancellationToken);
_flushCancellationToken,
analytics.Configuration.HttpConfig);
}
}
}
1 change: 1 addition & 0 deletions Samples/UnitySample/UnityHTTPClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ IEnumerator PostRequest(NetworkRequest networkRequest)
using (var request = UnityWebRequest.Put(networkRequest.URL, networkRequest.Data))
{
request.SetRequestHeader("Content-Type", "text/plain");
request.SetRequestHeader("Authorization", BasicAuthorization);
yield return request.SendWebRequest();

networkRequest.Response.StatusCode = (int)request.responseCode;
Expand Down
Loading