This is an automated email from the ASF dual-hosted git repository.

curth pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-adbc.git


The following commit(s) were added to refs/heads/main by this push:
     new cf68f9c19 fix(csharp/test/Drivers/Databricks): Enrich RetryHttpHandler 
with other status codes (#3186)
cf68f9c19 is described below

commit cf68f9c19efbce94de78cafc2f62d5f4b0660d1e
Author: Alex Guo <133057192+alexguo...@users.noreply.github.com>
AuthorDate: Tue Jul 22 15:04:11 2025 -0700

    fix(csharp/test/Drivers/Databricks): Enrich RetryHttpHandler with other 
status codes (#3186)
    
    Follow up of
    https://github.com/apache/arrow-adbc/pull/3177#discussion_r2220493096
    
    ## Proposed Changes
    - Added support for additional HTTP status codes: 408 (Request Timeout),
    502 (Bad Gateway), and 504 (Gateway Timeout), in addition to the
    existing 503 (Service Unavailable)
    - Implemented exponential backoff with jitter when no Retry-After header
    is present
    
    ## Testing
    Unit tests
    `dotnet test --filter "FullyQualifiedName~RetryHttpHandlerTest"`
    ```
    [xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.1.1+bf6400fd51 (64-bit 
.NET 8.0.7)
    [xUnit.net 00:00:00.06]   Discovering: 
Apache.Arrow.Adbc.Tests.Drivers.Databricks
    [xUnit.net 00:00:00.15]   Discovered:  
Apache.Arrow.Adbc.Tests.Drivers.Databricks
    [xUnit.net 00:00:00.16]   Starting:    
Apache.Arrow.Adbc.Tests.Drivers.Databricks
    [xUnit.net 00:00:25.28]   Finished:    
Apache.Arrow.Adbc.Tests.Drivers.Databricks
      Apache.Arrow.Adbc.Tests.Drivers.Databricks test net8.0 succeeded (26.2s)
    
    Test summary: total: 14, failed: 0, succeeded: 14, skipped: 0, duration: 
26.2s
    Build succeeded in 30.3s
    ```
---
 csharp/src/Drivers/Databricks/RetryHttpHandler.cs  |  93 ++++++++++---
 .../Databricks/Unit/RetryHttpHandlerTest.cs        | 148 +++++++++++++++++++--
 2 files changed, 205 insertions(+), 36 deletions(-)

diff --git a/csharp/src/Drivers/Databricks/RetryHttpHandler.cs 
b/csharp/src/Drivers/Databricks/RetryHttpHandler.cs
index 736c51a43..0f7f0b39e 100644
--- a/csharp/src/Drivers/Databricks/RetryHttpHandler.cs
+++ b/csharp/src/Drivers/Databricks/RetryHttpHandler.cs
@@ -25,17 +25,19 @@ using System.IO;
 namespace Apache.Arrow.Adbc.Drivers.Databricks
 {
     /// <summary>
-    /// HTTP handler that implements retry behavior for 503 responses with 
Retry-After headers.
+    /// HTTP handler that implements retry behavior for 408, 502, 503, and 504 
responses.
+    /// Uses Retry-After header if present, otherwise uses exponential backoff.
     /// </summary>
     internal class RetryHttpHandler : DelegatingHandler
     {
         private readonly int _retryTimeoutSeconds;
+        private readonly int _initialBackoffSeconds = 1;
+        private readonly int _maxBackoffSeconds = 32;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="RetryHttpHandler"/> 
class.
         /// </summary>
         /// <param name="innerHandler">The inner handler to delegate 
to.</param>
-        /// <param name="retryEnabled">Whether retry behavior is 
enabled.</param>
         /// <param name="retryTimeoutSeconds">Maximum total time in seconds to 
retry before failing.</param>
         public RetryHttpHandler(HttpMessageHandler innerHandler, int 
retryTimeoutSeconds)
             : base(innerHandler)
@@ -44,7 +46,7 @@ namespace Apache.Arrow.Adbc.Drivers.Databricks
         }
 
         /// <summary>
-        /// Sends an HTTP request to the inner handler with retry logic for 
503 responses.
+        /// Sends an HTTP request to the inner handler with retry logic for 
retryable status codes.
         /// </summary>
         protected override async Task<HttpResponseMessage> SendAsync(
             HttpRequestMessage request,
@@ -58,6 +60,8 @@ namespace Apache.Arrow.Adbc.Drivers.Databricks
             HttpResponseMessage response;
             string? lastErrorMessage = null;
             DateTime startTime = DateTime.UtcNow;
+            int attemptCount = 0;
+            int currentBackoffSeconds = _initialBackoffSeconds;
             int totalRetrySeconds = 0;
 
             do
@@ -70,28 +74,48 @@ namespace Apache.Arrow.Adbc.Drivers.Databricks
 
                 response = await base.SendAsync(request, cancellationToken);
 
-                // If it's not a 503 response, return immediately
-                if (response.StatusCode != HttpStatusCode.ServiceUnavailable)
+                // If it's not a retryable status code, return immediately
+                if (!IsRetryableStatusCode(response.StatusCode))
                 {
                     return response;
                 }
 
-                // Check for Retry-After header
-                if (!response.Headers.TryGetValues("Retry-After", out var 
retryAfterValues))
+                attemptCount++;
+
+                // Check if we've exceeded the timeout
+                TimeSpan elapsedTime = DateTime.UtcNow - startTime;
+                if (_retryTimeoutSeconds > 0 && elapsedTime.TotalSeconds > 
_retryTimeoutSeconds)
                 {
-                    // No Retry-After header, so return the response as is
-                    return response;
+                    // We've exceeded the timeout, so break out of the loop
+                    break;
                 }
 
-                // Parse the Retry-After value
-                string retryAfterValue = string.Join(",", retryAfterValues);
-                if (!int.TryParse(retryAfterValue, out int retryAfterSeconds) 
|| retryAfterSeconds <= 0)
+                int waitSeconds;
+
+                // Check for Retry-After header
+                if (response.Headers.TryGetValues("Retry-After", out var 
retryAfterValues))
                 {
-                    // Invalid Retry-After value, return the response as is
-                    return response;
+                    // Parse the Retry-After value
+                    string retryAfterValue = string.Join(",", 
retryAfterValues);
+                    if (int.TryParse(retryAfterValue, out int 
retryAfterSeconds) && retryAfterSeconds > 0)
+                    {
+                        // Use the Retry-After value
+                        waitSeconds = retryAfterSeconds;
+                        lastErrorMessage = $"Service temporarily unavailable 
(HTTP {(int)response.StatusCode}). Using server-specified retry after 
{waitSeconds} seconds. Attempt {attemptCount}.";
+                    }
+                    else
+                    {
+                        // Invalid Retry-After value, use exponential backoff
+                        waitSeconds = 
CalculateBackoffWithJitter(currentBackoffSeconds);
+                        lastErrorMessage = $"Service temporarily unavailable 
(HTTP {(int)response.StatusCode}). Invalid Retry-After header, using 
exponential backoff of {waitSeconds} seconds. Attempt {attemptCount}.";
+                    }
+                }
+                else
+                {
+                    // No Retry-After header, use exponential backoff
+                    waitSeconds = 
CalculateBackoffWithJitter(currentBackoffSeconds);
+                    lastErrorMessage = $"Service temporarily unavailable (HTTP 
{(int)response.StatusCode}). Using exponential backoff of {waitSeconds} 
seconds. Attempt {attemptCount}.";
                 }
-
-                lastErrorMessage = $"Service temporarily unavailable (HTTP 
503). Retry after {retryAfterSeconds} seconds.";
 
                 // Dispose the response before retrying
                 response.Dispose();
@@ -99,16 +123,19 @@ namespace Apache.Arrow.Adbc.Drivers.Databricks
                 // Reset the request content for the next attempt
                 request.Content = null;
 
-                // Check if we've exceeded the timeout
-                totalRetrySeconds += retryAfterSeconds;
+                // Update total retry time
+                totalRetrySeconds += waitSeconds;
                 if (_retryTimeoutSeconds > 0 && totalRetrySeconds > 
_retryTimeoutSeconds)
                 {
                     // We've exceeded the timeout, so break out of the loop
                     break;
                 }
 
-                // Wait for the specified retry time
-                await Task.Delay(TimeSpan.FromSeconds(retryAfterSeconds), 
cancellationToken);
+                // Wait for the calculated time
+                await Task.Delay(TimeSpan.FromSeconds(waitSeconds), 
cancellationToken);
+
+                // Increase backoff for next attempt (exponential backoff)
+                currentBackoffSeconds = Math.Min(currentBackoffSeconds * 2, 
_maxBackoffSeconds);
             } while (!cancellationToken.IsCancellationRequested);
 
             // If we get here, we've either exceeded the timeout or been 
cancelled
@@ -121,10 +148,32 @@ namespace Apache.Arrow.Adbc.Drivers.Databricks
                 .SetSqlState("08001");
         }
 
+        /// <summary>
+        /// Determines if the status code is one that should be retried.
+        /// </summary>
+        private bool IsRetryableStatusCode(HttpStatusCode statusCode)
+        {
+            return statusCode == HttpStatusCode.RequestTimeout ||      // 408
+                   statusCode == HttpStatusCode.BadGateway ||          // 502
+                   statusCode == HttpStatusCode.ServiceUnavailable ||  // 503
+                   statusCode == HttpStatusCode.GatewayTimeout;        // 504
+        }
+
+        /// <summary>
+        /// Calculates backoff time with jitter to avoid thundering herd 
problem.
+        /// </summary>
+        private int CalculateBackoffWithJitter(int baseBackoffSeconds)
+        {
+            // Add jitter by randomizing between 80-120% of the base backoff 
time
+            Random random = new Random();
+            double jitterFactor = 0.8 + (random.NextDouble() * 0.4); // 
Between 0.8 and 1.2
+            return (int)Math.Max(1, baseBackoffSeconds * jitterFactor);
+        }
+
         /// <summary>
         /// Clones an HttpContent object so it can be reused for retries.
-        /// per .net guidance, we should not reuse the http content across 
multiple
-        /// request, as it maybe disposed.
+        /// Per .NET guidance, we should not reuse the HTTP content across 
multiple
+        /// requests, as it may be disposed.
         /// </summary>
         private static async Task<HttpContent> 
CloneHttpContentAsync(HttpContent content)
         {
diff --git a/csharp/test/Drivers/Databricks/Unit/RetryHttpHandlerTest.cs 
b/csharp/test/Drivers/Databricks/Unit/RetryHttpHandlerTest.cs
index 0aa8ba55a..d21fe04e6 100644
--- a/csharp/test/Drivers/Databricks/Unit/RetryHttpHandlerTest.cs
+++ b/csharp/test/Drivers/Databricks/Unit/RetryHttpHandlerTest.cs
@@ -97,10 +97,10 @@ namespace Apache.Arrow.Adbc.Tests.Drivers.Databricks.Unit
         }
 
         /// <summary>
-        /// Tests that the RetryHttpHandler handles non-503 responses 
correctly.
+        /// Tests that the RetryHttpHandler handles non-retryable responses 
correctly.
         /// </summary>
         [Fact]
-        public async Task RetryAfterHandlerHandlesNon503Response()
+        public async Task RetryAfterHandlerHandlesNonRetryableResponse()
         {
             // Create a mock handler that returns a 404 response
             var mockHandler = new MockHttpMessageHandler(
@@ -125,10 +125,10 @@ namespace Apache.Arrow.Adbc.Tests.Drivers.Databricks.Unit
         }
 
         /// <summary>
-        /// Tests that the RetryHttpHandler handles 503 responses without 
Retry-After headers correctly.
+        /// Tests that the RetryHttpHandler handles 503 responses without 
Retry-After headers using exponential backoff.
         /// </summary>
         [Fact]
-        public async Task RetryAfterHandlerHandles503WithoutRetryAfterHeader()
+        public async Task 
RetryHandlerUsesExponentialBackoffFor503WithoutRetryAfterHeader()
         {
             // Create a mock handler that returns a 503 response without a 
Retry-After header
             var mockHandler = new MockHttpMessageHandler(
@@ -143,20 +143,26 @@ namespace Apache.Arrow.Adbc.Tests.Drivers.Databricks.Unit
             // Create an HttpClient with our handler
             var httpClient = new HttpClient(retryHandler);
 
+            // Set the mock handler to return a success response after the 
second retry
+            mockHandler.SetResponseAfterRetryCount(2, new 
HttpResponseMessage(HttpStatusCode.OK)
+            {
+                Content = new StringContent("Success")
+            });
+
             // Send a request
             var response = await httpClient.GetAsync("http://test.com";);
 
-            // Verify the response is 503
-            Assert.Equal(HttpStatusCode.ServiceUnavailable, 
response.StatusCode);
-            Assert.Equal("Service Unavailable", await 
response.Content.ReadAsStringAsync());
-            Assert.Equal(1, mockHandler.RequestCount); // Only the initial 
request, no retries
+            // Verify the response is OK
+            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+            Assert.Equal("Success", await 
response.Content.ReadAsStringAsync());
+            Assert.Equal(3, mockHandler.RequestCount); // Initial request + 2 
retries
         }
 
         /// <summary>
-        /// Tests that the RetryHttpHandler handles invalid Retry-After 
headers correctly.
+        /// Tests that the RetryHttpHandler handles invalid Retry-After 
headers by using exponential backoff.
         /// </summary>
         [Fact]
-        public async Task RetryAfterHandlerHandlesInvalidRetryAfterHeader()
+        public async Task 
RetryHandlerUsesExponentialBackoffForInvalidRetryAfterHeader()
         {
             // Create a mock handler that returns a 503 response with an 
invalid Retry-After header
             var mockHandler = new MockHttpMessageHandler(
@@ -173,6 +179,12 @@ namespace Apache.Arrow.Adbc.Tests.Drivers.Databricks.Unit
             response.Headers.TryAddWithoutValidation("Retry-After", "invalid");
             mockHandler.SetResponseAfterRetryCount(0, response);
 
+            // Set the mock handler to return a success response after the 
first retry
+            mockHandler.SetResponseAfterRetryCount(1, new 
HttpResponseMessage(HttpStatusCode.OK)
+            {
+                Content = new StringContent("Success")
+            });
+
             // Create the RetryHttpHandler with retry enabled
             var retryHandler = new RetryHttpHandler(mockHandler, 5);
 
@@ -182,10 +194,118 @@ namespace Apache.Arrow.Adbc.Tests.Drivers.Databricks.Unit
             // Send a request
             response = await httpClient.GetAsync("http://test.com";);
 
-            // Verify the response is 503
-            Assert.Equal(HttpStatusCode.ServiceUnavailable, 
response.StatusCode);
-            Assert.Equal("Service Unavailable", await 
response.Content.ReadAsStringAsync());
-            Assert.Equal(1, mockHandler.RequestCount); // Only the initial 
request, no retries
+            // Verify the response is OK
+            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+            Assert.Equal("Success", await 
response.Content.ReadAsStringAsync());
+            Assert.Equal(2, mockHandler.RequestCount); // Initial request + 1 
retry
+        }
+
+        /// <summary>
+        /// Tests that the RetryHttpHandler properly processes retryable 
status codes.
+        /// </summary>
+        [Theory]
+        [InlineData(HttpStatusCode.RequestTimeout, "Request Timeout")]      // 
408
+        [InlineData(HttpStatusCode.BadGateway, "Bad Gateway")]              // 
502
+        [InlineData(HttpStatusCode.ServiceUnavailable, "Service Unavailable")] 
// 503
+        [InlineData(HttpStatusCode.GatewayTimeout, "Gateway Timeout")]      // 
504
+        public async Task 
RetryHandlerProcessesRetryableStatusCodes(HttpStatusCode statusCode, string 
errorMessage)
+        {
+            // Create a mock handler that returns the specified status code
+            var mockHandler = new MockHttpMessageHandler(
+                new HttpResponseMessage(statusCode)
+                {
+                    Content = new StringContent(errorMessage)
+                });
+
+            // Create the RetryHttpHandler with retry enabled
+            var retryHandler = new RetryHttpHandler(mockHandler, 5);
+
+            // Create an HttpClient with our handler
+            var httpClient = new HttpClient(retryHandler);
+
+            // Set the mock handler to return a success response after the 
first retry
+            mockHandler.SetResponseAfterRetryCount(1, new 
HttpResponseMessage(HttpStatusCode.OK)
+            {
+                Content = new StringContent("Success")
+            });
+
+            // Send a request
+            var response = await httpClient.GetAsync("http://test.com";);
+
+            // Verify the response is OK
+            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+            Assert.Equal("Success", await 
response.Content.ReadAsStringAsync());
+            Assert.Equal(2, mockHandler.RequestCount); // Initial request + 1 
retry
+        }
+
+        /// <summary>
+        /// Tests that the RetryHttpHandler properly handles multiple retries 
with exponential backoff.
+        /// </summary>
+        [Fact]
+        public async Task 
RetryHandlerHandlesMultipleRetriesWithExponentialBackoff()
+        {
+            // Create a mock handler that returns a 503 response without a 
Retry-After header
+            var mockHandler = new MockHttpMessageHandler(
+                new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
+                {
+                    Content = new StringContent("Service Unavailable")
+                });
+
+            // Create the RetryHttpHandler with retry enabled and a generous 
timeout
+            var retryHandler = new RetryHttpHandler(mockHandler, 10);
+
+            // Create an HttpClient with our handler
+            var httpClient = new HttpClient(retryHandler);
+
+            // Set the mock handler to return a success response after the 
third retry
+            mockHandler.SetResponseAfterRetryCount(3, new 
HttpResponseMessage(HttpStatusCode.OK)
+            {
+                Content = new StringContent("Success")
+            });
+
+            // Send a request
+            var response = await httpClient.GetAsync("http://test.com";);
+
+            // Verify the response is OK
+            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+            Assert.Equal("Success", await 
response.Content.ReadAsStringAsync());
+            Assert.Equal(4, mockHandler.RequestCount); // Initial request + 3 
retries
+        }
+
+        /// <summary>
+        /// Tests that the RetryHttpHandler throws an exception when the 
server keeps returning errors
+        /// and we reach the timeout with exponential backoff.
+        /// </summary>
+        [Theory]
+        [InlineData(HttpStatusCode.RequestTimeout)]      // 408
+        [InlineData(HttpStatusCode.BadGateway)]          // 502
+        [InlineData(HttpStatusCode.ServiceUnavailable)]  // 503
+        [InlineData(HttpStatusCode.GatewayTimeout)]      // 504
+        public async Task 
RetryHandlerThrowsWhenServerNeverRecovers(HttpStatusCode statusCode)
+        {
+            // Create a mock handler that always returns the error status code
+            var mockHandler = new MockHttpMessageHandler(
+                new HttpResponseMessage(statusCode)
+                {
+                    Content = new StringContent($"Error: {statusCode}")
+                });
+
+            // Create the RetryHttpHandler with a short timeout to make the 
test run faster
+            var retryHandler = new RetryHttpHandler(mockHandler, 3);
+
+            // Create an HttpClient with our handler
+            var httpClient = new HttpClient(retryHandler);
+
+            // Send a request and expect a DatabricksException
+            var exception = await 
Assert.ThrowsAsync<DatabricksException>(async () =>
+                await httpClient.GetAsync("http://test.com";));
+
+            // Verify the exception has the correct SQL state
+            Assert.Contains("08001", exception.SqlState);
+            Assert.Equal(AdbcStatusCode.IOError, exception.Status);
+
+            // Verify we tried multiple times before giving up
+            Assert.True(mockHandler.RequestCount > 1, $"Expected multiple 
requests, but got {mockHandler.RequestCount}");
         }
 
         /// <summary>

Reply via email to