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