diff --git a/Src/Support/Google.Apis.Core/Http/BackOffHandler.cs b/Src/Support/Google.Apis.Core/Http/BackOffHandler.cs
index dc12abfa7dd..1b4aaa906dc 100644
--- a/Src/Support/Google.Apis.Core/Http/BackOffHandler.cs
+++ b/Src/Support/Google.Apis.Core/Http/BackOffHandler.cs
@@ -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;
@@ -122,10 +121,27 @@ public BackOffHandler(Initializer initializer)
///
public virtual async Task 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;
@@ -138,10 +154,10 @@ public virtual async Task HandleResponseAsync(HandleUnsuccessfulResponseAr
///
public virtual async Task 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;
@@ -152,10 +168,10 @@ public virtual async Task HandleExceptionAsync(HandleExceptionArgs args)
///
/// 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 false. Otherwise the current thread
- /// will block for x milliseconds (x is defined by the instance), and this handler
- /// returns true.
+ /// will block for x milliseconds (x is defined by the instance or the Retry-After header),
+ /// and this handler returns true.
///
- private async Task HandleAsync(bool supportsRetry, int currentFailedTry,
+ private async Task HandleAsync(bool supportsRetry, int currentFailedTry, TimeSpan? retryAfterDelay,
CancellationToken cancellationToken)
{
if (!supportsRetry || BackOff.MaxNumOfRetries < currentFailedTry)
@@ -163,7 +179,12 @@ private async Task HandleAsync(bool supportsRetry, int 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;
diff --git a/Src/Support/Google.Apis.Tests/Apis/Http/ConfigurableMessageHandlerTest.cs b/Src/Support/Google.Apis.Tests/Apis/Http/ConfigurableMessageHandlerTest.cs
index 16e6e653c3c..50b59cf0357 100644
--- a/Src/Support/Google.Apis.Tests/Apis/Http/ConfigurableMessageHandlerTest.cs
+++ b/Src/Support/Google.Apis.Tests/Apis/Http/ConfigurableMessageHandlerTest.cs
@@ -311,6 +311,9 @@ private class UnsuccessfulResponseMessageHandler : CountableMessageHandler
///
public int CancelRequestNum { get; set; }
+ /// Gets or sets an optional action to configure the response message.
+ public Action ConfigureResponseAction { get; set; }
+
protected override Task SendAsyncCore(HttpRequestMessage request,
CancellationToken cancellationToken)
{
@@ -320,7 +323,12 @@ protected override Task SendAsyncCore(HttpRequestMessage re
}
TaskCompletionSource tcs = new TaskCompletionSource();
- 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;
}
@@ -394,7 +402,114 @@ public async Task SendAsync_AbnormalResponse_WithoutUnsuccessfulReponseHandler()
Assert.Equal(1, handler.Calls);
}
}
+
+ ///
+ /// Tests that the back-off handler respects the Retry-After header with a delta interval.
+ ///
+ [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);
+ }
+ }
+
+ ///
+ /// Tests that the back-off handler respects the Retry-After header with an absolute date.
+ ///
+ [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}");
+ }
+ }
+
+
+ ///
+ /// 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.
+ ///
+ [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