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
39 changes: 30 additions & 9 deletions Src/Support/Google.Apis.Core/Http/BackOffHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ limitations under the License.
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

using Google.Apis.Logging;
using Google.Apis.Util;

Expand Down Expand Up @@ -122,10 +121,27 @@ public BackOffHandler(Initializer initializer)
/// <inheritdoc/>
public virtual async Task<bool> HandleResponseAsync(HandleUnsuccessfulResponseArgs args)
{
// if the func returns true try to handle this current failed try
// If the func returns true try to handle this current failed try
if (HandleUnsuccessfulResponseFunc != null && HandleUnsuccessfulResponseFunc(args.Response))
{
return await HandleAsync(args.SupportsRetry, args.CurrentFailedTry, args.CancellationToken)
TimeSpan? retryAfterDelay = null;

// Extract the Retry-After header if present in the HTTP response
if (args.Response.Headers?.RetryAfter != null)
{
if (args.Response.Headers.RetryAfter.Delta.HasValue)
{
// Case 1: It's a relative delay (e.g., seconds to wait)
retryAfterDelay = args.Response.Headers.RetryAfter.Delta.Value;
}
else if (args.Response.Headers.RetryAfter.Date.HasValue)
{
// Case 2: It's an absolute date/time. Calculate the difference from now
retryAfterDelay = args.Response.Headers.RetryAfter.Date.Value - DateTimeOffset.UtcNow;
}
}

return await HandleAsync(args.SupportsRetry, args.CurrentFailedTry, retryAfterDelay, args.CancellationToken)
.ConfigureAwait(false);
}
return false;
Expand All @@ -138,10 +154,10 @@ public virtual async Task<bool> HandleResponseAsync(HandleUnsuccessfulResponseAr
/// <inheritdoc/>
public virtual async Task<bool> HandleExceptionAsync(HandleExceptionArgs args)
{
// if the func returns true try to handle this current failed try
if (HandleExceptionFunc != null && HandleExceptionFunc(args.Exception))
{
return await HandleAsync(args.SupportsRetry, args.CurrentFailedTry, args.CancellationToken)
// Raw exceptions (e.g., network timeouts) do not have an HTTP response, so pass null
return await HandleAsync(args.SupportsRetry, args.CurrentFailedTry, null, args.CancellationToken)
.ConfigureAwait(false);
}
return false;
Expand All @@ -152,18 +168,23 @@ public virtual async Task<bool> HandleExceptionAsync(HandleExceptionArgs args)
/// <summary>
/// Handles back-off. In case the request doesn't support retry or the back-off time span is greater than the
/// maximum time span allowed for a request, the handler returns <c>false</c>. Otherwise the current thread
/// will block for x milliseconds (x is defined by the <see cref="BackOff"/> instance), and this handler
/// returns <c>true</c>.
/// will block for x milliseconds (x is defined by the <see cref="BackOff"/> instance or the Retry-After header),
/// and this handler returns <c>true</c>.
/// </summary>
private async Task<bool> HandleAsync(bool supportsRetry, int currentFailedTry,
private async Task<bool> HandleAsync(bool supportsRetry, int currentFailedTry, TimeSpan? retryAfterDelay,
CancellationToken cancellationToken)
{
if (!supportsRetry || BackOff.MaxNumOfRetries < currentFailedTry)
{
return false;
}

TimeSpan ts = BackOff.GetNextBackOff(currentFailedTry);
// If the Retry-After header is present, honor it (clamping negative values to Zero due to potential clock skew).
// Otherwise, fallback to the standard exponential back-off algorithm.
TimeSpan ts = retryAfterDelay.HasValue
? (retryAfterDelay.Value < TimeSpan.Zero ? TimeSpan.Zero : retryAfterDelay.Value)
: BackOff.GetNextBackOff(currentFailedTry);

if (ts > MaxTimeSpan || ts < TimeSpan.Zero)
{
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,9 @@ private class UnsuccessfulResponseMessageHandler : CountableMessageHandler
/// </summary>
public int CancelRequestNum { get; set; }

/// <summary>Gets or sets an optional action to configure the response message.</summary>
public Action<HttpResponseMessage> ConfigureResponseAction { get; set; }

protected override Task<HttpResponseMessage> SendAsyncCore(HttpRequestMessage request,
CancellationToken cancellationToken)
{
Expand All @@ -320,7 +323,12 @@ protected override Task<HttpResponseMessage> SendAsyncCore(HttpRequestMessage re
}

TaskCompletionSource<HttpResponseMessage> tcs = new TaskCompletionSource<HttpResponseMessage>();
tcs.SetResult(new HttpResponseMessage { StatusCode = ResponseStatusCode });
var response = new HttpResponseMessage { StatusCode = ResponseStatusCode };

// If a custom action is provided, run it to inject headers like Retry-After
ConfigureResponseAction?.Invoke(response);

tcs.SetResult(response);
return tcs.Task;
}

Expand Down Expand Up @@ -394,7 +402,114 @@ public async Task SendAsync_AbnormalResponse_WithoutUnsuccessfulReponseHandler()
Assert.Equal(1, handler.Calls);
}
}

/// <summary>
/// Tests that the back-off handler respects the Retry-After header with a delta interval.
/// </summary>
[Fact]
public async Task SendAsync_BackOffUnsuccessfulResponseHandler_RetryAfter_Delta()
{
var initializer = new BackOffHandler.Initializer(new ExponentialBackOff(TimeSpan.Zero))
{
HandleUnsuccessfulResponseFunc = (r) => (int)r.StatusCode == 429
};

var handler = new UnsuccessfulResponseMessageHandler
{
ResponseStatusCode = (HttpStatusCode)429,
ConfigureResponseAction = (response) =>
{
response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(5));
}
};

var configurableHandler = new ConfigurableMessageHandler(handler) { NumTries = 2 };
var boHandler = new MockBackOffHandler(initializer);
configurableHandler.AddUnsuccessfulResponseHandler(boHandler);

using (var client = new HttpClient(configurableHandler))
{
var request = new HttpRequestMessage(HttpMethod.Get, "https://test-retry-after-delta");
await client.SendAsync(request);

Assert.Single(boHandler.Waits);
Assert.Equal(5, boHandler.Waits[0].TotalSeconds);
}
}

/// <summary>
/// Tests that the back-off handler respects the Retry-After header with an absolute date.
/// </summary>
[Fact]
public async Task SendAsync_BackOffUnsuccessfulResponseHandler_RetryAfter_Date()
{
var initializer = new BackOffHandler.Initializer(new ExponentialBackOff(TimeSpan.Zero))
{
HandleUnsuccessfulResponseFunc = (r) => (int)r.StatusCode == 429
};

var handler = new UnsuccessfulResponseMessageHandler
{
ResponseStatusCode = (HttpStatusCode)429,
ConfigureResponseAction = (response) =>
{
response.Headers.RetryAfter = new RetryConditionHeaderValue(DateTimeOffset.UtcNow.AddSeconds(8));
}
};

var configurableHandler = new ConfigurableMessageHandler(handler) { NumTries = 2 };
var boHandler = new MockBackOffHandler(initializer);
configurableHandler.AddUnsuccessfulResponseHandler(boHandler);

using (var client = new HttpClient(configurableHandler))
{
var request = new HttpRequestMessage(HttpMethod.Get, "https://test-retry-after-date");
await client.SendAsync(request);

Assert.Single(boHandler.Waits);
Assert.True(boHandler.Waits[0].TotalSeconds >= 7 && boHandler.Waits[0].TotalSeconds <= 9,
$"Expected wait around 8 seconds, but was {boHandler.Waits[0].TotalSeconds}");
}
}


/// <summary>
/// Tests that the back-off handler respects a zero or negative Retry-After header
/// by clamping it to Zero instead of falling back to exponential back-off.
/// </summary>
[Fact]
public async Task SendAsync_BackOffUnsuccessfulResponseHandler_RetryAfter_ZeroOrNegative()
{
var initializer = new BackOffHandler.Initializer(new ExponentialBackOff(TimeSpan.FromSeconds(10)))
{
HandleUnsuccessfulResponseFunc = (r) => (int)r.StatusCode == 429
};

var handler = new UnsuccessfulResponseMessageHandler
{
ResponseStatusCode = (HttpStatusCode)429,
ConfigureResponseAction = (response) =>
{
// Simulate a negative delay (e.g., clock skew where server time is slightly in the past)
response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(-2));
}
};

var configurableHandler = new ConfigurableMessageHandler(handler) { NumTries = 2 };
var boHandler = new MockBackOffHandler(initializer);
configurableHandler.AddUnsuccessfulResponseHandler(boHandler);

using (var client = new HttpClient(configurableHandler))
{
var request = new HttpRequestMessage(HttpMethod.Get, "https://test-retry-after-negative");
await client.SendAsync(request);

Assert.Single(boHandler.Waits);
// Crucial: It must be clamped to EXACTLY 0 seconds, NOT fallback to the 10 seconds of ExponentialBackOff
Assert.Equal(0, boHandler.Waits[0].TotalSeconds);
}
}

#endregion

#region Exception Handler
Expand Down
Loading