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 30626e576 feat(csharp): Add retry-after behavior for 503 responses in
Spark ADBC driver (#2664)
30626e576 is described below
commit 30626e57651d77cb1d83dcce81cf4405670a968c
Author: Jade Wang <[email protected]>
AuthorDate: Tue Apr 22 11:21:10 2025 -0700
feat(csharp): Add retry-after behavior for 503 responses in Spark ADBC
driver (#2664)
## Description
This PR implements retry-after behavior for the Spark ADBC driver when
receiving 503 responses with Retry-After headers. This is particularly
useful for Databricks clusters that may return 503 responses when a
cluster is starting up or experiencing temporary unavailability.
## Changes
- Added new configuration parameters:
- `adbc.spark.temporarily_unavailable_retry` (default: 1 - enabled)
- `adbc.spark.temporarily_unavailable_retry_timeout` (default: 900
seconds)
- Created a `RetryHttpHandler` class that wraps the existing
`HttpClientHandler` to handle 503 responses
- Modified `SparkHttpConnection` to use the new retry handler
- Added comprehensive unit tests for the retry behavior
## Implementation Details
When a 503 response with a Retry-After header is received:
1. The handler will wait for the number of seconds specified in the
header
2. It will then retry the request
3. If another 503 response is received, it will continue retrying
4. If the total retry time exceeds the configured timeout, it will fail
with an appropriate error message
## Testing
Added unit tests to verify:
- Retry behavior for 503 responses with Retry-After headers
- Timeout behavior when retry time exceeds the configured limit
- Handling of invalid or missing Retry-After headers
- Disabling retry behavior via configuration
- Parameter validationv
---
.../Drivers/Apache/Spark/SparkHttpConnection.cs | 9 +-
.../src/Drivers/Databricks/DatabricksConnection.cs | 51 +++++
.../src/Drivers/Databricks/DatabricksException.cs | 69 ++++++
.../src/Drivers/Databricks/DatabricksParameters.cs | 10 +
csharp/src/Drivers/Databricks/RetryHttpHandler.cs | 147 +++++++++++++
.../Drivers/Databricks/DatabricksConnectionTest.cs | 5 +
.../Drivers/Databricks/RetryHttpHandlerTest.cs | 244 +++++++++++++++++++++
7 files changed, 533 insertions(+), 2 deletions(-)
diff --git a/csharp/src/Drivers/Apache/Spark/SparkHttpConnection.cs
b/csharp/src/Drivers/Apache/Spark/SparkHttpConnection.cs
index 9cabd1ac4..6806d0c80 100644
--- a/csharp/src/Drivers/Apache/Spark/SparkHttpConnection.cs
+++ b/csharp/src/Drivers/Apache/Spark/SparkHttpConnection.cs
@@ -135,11 +135,17 @@ namespace Apache.Arrow.Adbc.Drivers.Apache.Spark
? connectTimeoutMsValue
: throw new
ArgumentOutOfRangeException(SparkParameters.ConnectTimeoutMilliseconds,
connectTimeoutMs, $"must be a value of 0 (infinite) or between 1 ..
{int.MaxValue}. default is 30000 milliseconds.");
}
+
TlsOptions = HiveServer2TlsImpl.GetHttpTlsOptions(Properties);
}
internal override IArrowArrayStream NewReader<T>(T statement, Schema
schema, TGetResultSetMetadataResp? metadataResp = null) => new
HiveServer2Reader(statement, schema, dataTypeConversion:
statement.Connection.DataTypeConversion);
+ protected virtual HttpMessageHandler CreateHttpHandler()
+ {
+ return HiveServer2TlsImpl.NewHttpClientHandler(TlsOptions);
+ }
+
protected override TTransport CreateTransport()
{
// Assumption: parameters have already been validated.
@@ -160,8 +166,7 @@ namespace Apache.Arrow.Adbc.Drivers.Apache.Spark
Uri baseAddress = GetBaseAddress(uri, hostName, path, port,
SparkParameters.HostName, TlsOptions.IsTlsEnabled);
AuthenticationHeaderValue? authenticationHeaderValue =
GetAuthenticationHeaderValue(authTypeValue, token, username, password,
access_token);
- HttpClientHandler httpClientHandler =
HiveServer2TlsImpl.NewHttpClientHandler(TlsOptions);
- HttpClient httpClient = new(httpClientHandler);
+ HttpClient httpClient = new(CreateHttpHandler());
httpClient.BaseAddress = baseAddress;
httpClient.DefaultRequestHeaders.Authorization =
authenticationHeaderValue;
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(s_userAgent);
diff --git a/csharp/src/Drivers/Databricks/DatabricksConnection.cs
b/csharp/src/Drivers/Databricks/DatabricksConnection.cs
index 45a496b64..aefe2df89 100644
--- a/csharp/src/Drivers/Databricks/DatabricksConnection.cs
+++ b/csharp/src/Drivers/Databricks/DatabricksConnection.cs
@@ -19,6 +19,7 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
+using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Apache.Arrow.Adbc.Drivers.Apache;
@@ -39,6 +40,8 @@ namespace Apache.Arrow.Adbc.Drivers.Databricks
private bool _useCloudFetch = true;
private bool _canDecompressLz4 = true;
private long _maxBytesPerFile = DefaultMaxBytesPerFile;
+ private const bool DefaultRetryOnUnavailable= true;
+ private const int DefaultTemporarilyUnavailableRetryTimeout = 500;
public DatabricksConnection(IReadOnlyDictionary<string, string>
properties) : base(properties)
{
@@ -122,6 +125,26 @@ namespace Apache.Arrow.Adbc.Drivers.Databricks
/// </summary>
internal long MaxBytesPerFile => _maxBytesPerFile;
+ /// <summary>
+ /// Gets a value indicating whether to retry requests that receive a
503 response with a Retry-After header.
+ /// </summary>
+ protected bool TemporarilyUnavailableRetry { get; private set; } =
DefaultRetryOnUnavailable;
+
+ /// <summary>
+ /// Gets the maximum total time in seconds to retry 503 responses
before failing.
+ /// </summary>
+ protected int TemporarilyUnavailableRetryTimeout { get; private set; }
= DefaultTemporarilyUnavailableRetryTimeout;
+
+ protected override HttpMessageHandler CreateHttpHandler()
+ {
+ var baseHandler = base.CreateHttpHandler();
+ if (TemporarilyUnavailableRetry)
+ {
+ return new RetryHttpHandler(baseHandler,
TemporarilyUnavailableRetryTimeout);
+ }
+ return baseHandler;
+ }
+
internal override IArrowArrayStream NewReader<T>(T statement, Schema
schema, TGetResultSetMetadataResp? metadataResp = null)
{
// Get result format from metadata response if available
@@ -259,6 +282,34 @@ namespace Apache.Arrow.Adbc.Drivers.Databricks
return "`" + value.Replace("`", "``") + "`";
}
+ protected override void ValidateOptions()
+ {
+ base.ValidateOptions();
+
+ if
(Properties.TryGetValue(DatabricksParameters.TemporarilyUnavailableRetry, out
string? tempUnavailableRetryStr))
+ {
+ if (!bool.TryParse(tempUnavailableRetryStr, out bool
tempUnavailableRetryValue))
+ {
+ throw new
ArgumentOutOfRangeException(DatabricksParameters.TemporarilyUnavailableRetry,
tempUnavailableRetryStr,
+ $"must be a value of false (disabled) or true
(enabled). Default is true.");
+ }
+
+ TemporarilyUnavailableRetry = tempUnavailableRetryValue;
+ }
+
+
+
if(Properties.TryGetValue(DatabricksParameters.TemporarilyUnavailableRetryTimeout,
out string? tempUnavailableRetryTimeoutStr))
+ {
+ if (!int.TryParse(tempUnavailableRetryTimeoutStr, out int
tempUnavailableRetryTimeoutValue) ||
+ tempUnavailableRetryTimeoutValue < 0)
+ {
+ throw new
ArgumentOutOfRangeException(DatabricksParameters.TemporarilyUnavailableRetryTimeout,
tempUnavailableRetryTimeoutStr,
+ $"must be a value of 0 (retry indefinitely) or a
positive integer representing seconds. Default is 900 seconds (15 minutes).");
+ }
+ TemporarilyUnavailableRetryTimeout =
tempUnavailableRetryTimeoutValue;
+ }
+ }
+
protected override Task<TGetResultSetMetadataResp>
GetResultSetMetadataAsync(TGetSchemasResp response, CancellationToken
cancellationToken = default) =>
Task.FromResult(response.DirectResults.ResultSetMetadata);
protected override Task<TGetResultSetMetadataResp>
GetResultSetMetadataAsync(TGetCatalogsResp response, CancellationToken
cancellationToken = default) =>
diff --git a/csharp/src/Drivers/Databricks/DatabricksException.cs
b/csharp/src/Drivers/Databricks/DatabricksException.cs
new file mode 100644
index 000000000..c6c3d203a
--- /dev/null
+++ b/csharp/src/Drivers/Databricks/DatabricksException.cs
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System;
+
+namespace Apache.Arrow.Adbc.Drivers.Databricks
+{
+ public class DatabricksException : AdbcException
+ {
+ private string? _sqlState;
+ private int _nativeError;
+
+ public DatabricksException()
+ {
+ }
+
+ public DatabricksException(string message) : base(message)
+ {
+ }
+
+ public DatabricksException(string message, AdbcStatusCode statusCode)
: base(message, statusCode)
+ {
+ }
+
+ public DatabricksException(string message, Exception innerException) :
base(message, innerException)
+ {
+ }
+
+ public DatabricksException(string message, AdbcStatusCode statusCode,
Exception innerException) : base(message, statusCode, innerException)
+ {
+ }
+
+ public override string? SqlState
+ {
+ get { return _sqlState; }
+ }
+
+ public override int NativeError
+ {
+ get { return _nativeError; }
+ }
+
+ internal DatabricksException SetSqlState(string sqlState)
+ {
+ _sqlState = sqlState;
+ return this;
+ }
+
+ internal DatabricksException SetNativeError(int nativeError)
+ {
+ _nativeError = nativeError;
+ return this;
+ }
+ }
+}
diff --git a/csharp/src/Drivers/Databricks/DatabricksParameters.cs
b/csharp/src/Drivers/Databricks/DatabricksParameters.cs
index 1c963b4d7..c99fbde3e 100644
--- a/csharp/src/Drivers/Databricks/DatabricksParameters.cs
+++ b/csharp/src/Drivers/Databricks/DatabricksParameters.cs
@@ -75,6 +75,16 @@ namespace Apache.Arrow.Adbc.Drivers.Databricks
/// and value "true" will result in executing "set
use_cached_result=true" on the server.
/// </summary>
public const string ServerSidePropertyPrefix = "adbc.databricks.SSP_";
+ /// Controls whether to retry requests that receive a 503 response
with a Retry-After header.
+ /// Default value is true (enabled). Set to false to disable retry
behavior.
+ /// </summary>
+ public const string TemporarilyUnavailableRetry =
"adbc.spark.temporarily_unavailable_retry";
+
+ /// <summary>
+ /// Maximum total time in seconds to retry 503 responses before
failing.
+ /// Default value is 900 seconds (15 minutes). Set to 0 to retry
indefinitely.
+ /// </summary>
+ public const string TemporarilyUnavailableRetryTimeout =
"adbc.spark.temporarily_unavailable_retry_timeout";
}
/// <summary>
diff --git a/csharp/src/Drivers/Databricks/RetryHttpHandler.cs
b/csharp/src/Drivers/Databricks/RetryHttpHandler.cs
new file mode 100644
index 000000000..2a9f28161
--- /dev/null
+++ b/csharp/src/Drivers/Databricks/RetryHttpHandler.cs
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using System.IO;
+
+namespace Apache.Arrow.Adbc.Drivers.Databricks
+{
+ /// <summary>
+ /// HTTP handler that implements retry behavior for 503 responses with
Retry-After headers.
+ /// </summary>
+ internal class RetryHttpHandler : DelegatingHandler
+ {
+ private readonly int _retryTimeoutSeconds;
+
+ /// <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)
+ {
+ _retryTimeoutSeconds = retryTimeoutSeconds;
+ }
+
+ /// <summary>
+ /// Sends an HTTP request to the inner handler with retry logic for
503 responses.
+ /// </summary>
+ protected override async Task<HttpResponseMessage> SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ // Clone the request content if it's not null so we can reuse it
for retries
+ var requestContentClone = request.Content != null
+ ? await CloneHttpContentAsync(request.Content)
+ : null;
+
+ HttpResponseMessage response;
+ string? lastErrorMessage = null;
+ DateTime startTime = DateTime.UtcNow;
+ int totalRetrySeconds = 0;
+
+ do
+ {
+ // Set the content for each attempt (if needed)
+ if (requestContentClone != null && request.Content == null)
+ {
+ request.Content = await
CloneHttpContentAsync(requestContentClone);
+ }
+
+ response = await base.SendAsync(request, cancellationToken);
+
+ // If it's not a 503 response, return immediately
+ if (response.StatusCode != HttpStatusCode.ServiceUnavailable)
+ {
+ return response;
+ }
+
+ // Check for Retry-After header
+ if (!response.Headers.TryGetValues("Retry-After", out var
retryAfterValues))
+ {
+ // No Retry-After header, so 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)
+ {
+ // Invalid Retry-After value, return the response as is
+ return response;
+ }
+
+ lastErrorMessage = $"Service temporarily unavailable (HTTP
503). Retry after {retryAfterSeconds} seconds.";
+
+ // Dispose the response before retrying
+ response.Dispose();
+
+ // Reset the request content for the next attempt
+ request.Content = null;
+
+ // Check if we've exceeded the timeout
+ totalRetrySeconds += retryAfterSeconds;
+ 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);
+ } while (!cancellationToken.IsCancellationRequested);
+
+ // If we get here, we've either exceeded the timeout or been
cancelled
+ if (cancellationToken.IsCancellationRequested)
+ {
+ throw new OperationCanceledException("Request cancelled during
retry wait", cancellationToken);
+ }
+
+ throw new DatabricksException(
+ lastErrorMessage ?? "Service temporarily unavailable and retry
timeout exceeded",
+ AdbcStatusCode.IOError);
+ }
+
+ /// <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.
+ /// </summary>
+ private static async Task<HttpContent>
CloneHttpContentAsync(HttpContent content)
+ {
+ var ms = new MemoryStream();
+ await content.CopyToAsync(ms);
+ ms.Position = 0;
+
+ var clone = new StreamContent(ms);
+ if (content.Headers != null)
+ {
+ foreach (var header in content.Headers)
+ {
+ clone.Headers.Add(header.Key, header.Value);
+ }
+ }
+ return clone;
+ }
+ }
+}
diff --git a/csharp/test/Drivers/Databricks/DatabricksConnectionTest.cs
b/csharp/test/Drivers/Databricks/DatabricksConnectionTest.cs
index 3462f1505..859ee7e84 100644
--- a/csharp/test/Drivers/Databricks/DatabricksConnectionTest.cs
+++ b/csharp/test/Drivers/Databricks/DatabricksConnectionTest.cs
@@ -307,6 +307,11 @@ namespace Apache.Arrow.Adbc.Tests.Drivers.Databricks
Add(new(new() { /*[SparkParameters.Type] =
SparkServerTypeConstants.Databricks,*/ [SparkParameters.Token] = "abcdef",
[AdbcOptions.Uri] = "http-//hostname.com" }, typeof(ArgumentException)));
Add(new(new() { /*[SparkParameters.Type] =
SparkServerTypeConstants.Databricks,*/ [SparkParameters.Token] = "abcdef",
[AdbcOptions.Uri] = "httpxxz://hostname.com:1234567890" },
typeof(ArgumentException)));
Add(new(new() { /*[SparkParameters.Type] =
SparkServerTypeConstants.Databricks,*/ [SparkParameters.Token] = "abcdef",
[SparkParameters.HostName] = "valid.server.com", [AdbcOptions.Uri] =
"http://valid.hostname.com" }, typeof(ArgumentOutOfRangeException)));
+
+ // Tests for the new retry configuration parameters
+ Add(new(new() { [SparkParameters.Type] =
SparkServerTypeConstants.Http, [SparkParameters.HostName] = "valid.server.com",
[AdbcOptions.Username] = "user", [AdbcOptions.Password] = "myPassword",
[DatabricksParameters.TemporarilyUnavailableRetry] = "invalid" },
typeof(ArgumentOutOfRangeException)));
+ Add(new(new() { [SparkParameters.Type] =
SparkServerTypeConstants.Http, [SparkParameters.HostName] = "valid.server.com",
[AdbcOptions.Username] = "user", [AdbcOptions.Password] = "myPassword",
[DatabricksParameters.TemporarilyUnavailableRetryTimeout] = "invalid" },
typeof(ArgumentOutOfRangeException)));
+ Add(new(new() { [SparkParameters.Type] =
SparkServerTypeConstants.Http, [SparkParameters.HostName] = "valid.server.com",
[AdbcOptions.Username] = "user", [AdbcOptions.Password] = "myPassword",
[DatabricksParameters.TemporarilyUnavailableRetryTimeout] = "-1" },
typeof(ArgumentOutOfRangeException)));
}
}
}
diff --git a/csharp/test/Drivers/Databricks/RetryHttpHandlerTest.cs
b/csharp/test/Drivers/Databricks/RetryHttpHandlerTest.cs
new file mode 100644
index 000000000..fad2ad9eb
--- /dev/null
+++ b/csharp/test/Drivers/Databricks/RetryHttpHandlerTest.cs
@@ -0,0 +1,244 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one or more
+* contributor license agreements. See the NOTICE file distributed with
+* this work for additional information regarding copyright ownership.
+* The ASF licenses this file to You under the Apache License, Version 2.0
+* (the "License"); you may not use this file except in compliance with
+* the License. You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Apache.Arrow.Adbc.Drivers.Databricks;
+using Xunit;
+
+namespace Apache.Arrow.Adbc.Tests.Drivers.Databricks
+{
+ /// <summary>
+ /// Tests for the RetryHttpHandler class.
+ /// </summary>
+ public class RetryHttpHandlerTest
+ {
+ /// <summary>
+ /// Tests that the RetryHttpHandler properly processes 503 responses
with Retry-After headers.
+ /// </summary>
+ [Fact]
+ public async Task RetryAfterHandlerProcesses503Response()
+ {
+ // Create a mock handler that returns a 503 response with a
Retry-After header
+ var mockHandler = new MockHttpMessageHandler(
+ new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
+ {
+ Headers = { { "Retry-After", "1" } },
+ Content = new StringContent("Service Unavailable")
+ });
+
+ // Create the RetryHttpHandler with retry enabled and a 5-second
timeout
+ 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 throws an exception when the retry
timeout is exceeded.
+ /// </summary>
+ [Fact]
+ public async Task RetryAfterHandlerThrowsWhenTimeoutExceeded()
+ {
+ // Create a mock handler that always returns a 503 response with a
Retry-After header
+ var mockHandler = new MockHttpMessageHandler(
+ new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
+ {
+ Headers = { { "Retry-After", "2" } },
+ Content = new StringContent("Service Unavailable")
+ });
+
+ // Create the RetryHttpHandler with retry enabled and a 1-second
timeout
+ var retryHandler = new RetryHttpHandler(mockHandler, 1);
+
+ // Create an HttpClient with our handler
+ var httpClient = new HttpClient(retryHandler);
+
+ // Send a request and expect an AdbcException
+ var exception = await Assert.ThrowsAsync<AdbcException>(async () =>
+ await httpClient.GetAsync("http://test.com"));
+
+ // Verify the exception has the correct SQL state in the message
+ Assert.Contains("[SQLState: 08001]", exception.Message);
+ Assert.Equal(AdbcStatusCode.IOError, exception.Status);
+
+ // Verify we only tried once (since the Retry-After value of 2
exceeds our timeout of 1)
+ Assert.Equal(1, mockHandler.RequestCount);
+ }
+
+ /// <summary>
+ /// Tests that the RetryHttpHandler handles non-503 responses
correctly.
+ /// </summary>
+ [Fact]
+ public async Task RetryAfterHandlerHandlesNon503Response()
+ {
+ // Create a mock handler that returns a 404 response
+ var mockHandler = new MockHttpMessageHandler(
+ new HttpResponseMessage(HttpStatusCode.NotFound)
+ {
+ Content = new StringContent("Not Found")
+ });
+
+ // Create the RetryHttpHandler with retry enabled
+ var retryHandler = new RetryHttpHandler(mockHandler, 5);
+
+ // Create an HttpClient with our handler
+ var httpClient = new HttpClient(retryHandler);
+
+ // Send a request
+ var response = await httpClient.GetAsync("http://test.com");
+
+ // Verify the response is 404
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ Assert.Equal("Not Found", await
response.Content.ReadAsStringAsync());
+ Assert.Equal(1, mockHandler.RequestCount); // Only the initial
request, no retries
+ }
+
+ /// <summary>
+ /// Tests that the RetryHttpHandler handles 503 responses without
Retry-After headers correctly.
+ /// </summary>
+ [Fact]
+ public async Task RetryAfterHandlerHandles503WithoutRetryAfterHeader()
+ {
+ // 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
+ var retryHandler = new RetryHttpHandler(mockHandler, 5);
+
+ // Create an HttpClient with our handler
+ var httpClient = new HttpClient(retryHandler);
+
+ // 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
+ }
+
+ /// <summary>
+ /// Tests that the RetryHttpHandler handles invalid Retry-After
headers correctly.
+ /// </summary>
+ [Fact]
+ public async Task RetryAfterHandlerHandlesInvalidRetryAfterHeader()
+ {
+ // Create a mock handler that returns a 503 response with an
invalid Retry-After header
+ var mockHandler = new MockHttpMessageHandler(
+ new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
+ {
+ Content = new StringContent("Service Unavailable")
+ });
+
+ // Add the invalid Retry-After header directly in the test
+ var response = new
HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
+ {
+ Content = new StringContent("Service Unavailable")
+ };
+ response.Headers.TryAddWithoutValidation("Retry-After", "invalid");
+ mockHandler.SetResponseAfterRetryCount(0, response);
+
+ // Create the RetryHttpHandler with retry enabled
+ var retryHandler = new RetryHttpHandler(mockHandler, 5);
+
+ // Create an HttpClient with our handler
+ var httpClient = new HttpClient(retryHandler);
+
+ // 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
+ }
+
+ /// <summary>
+ /// Mock HttpMessageHandler for testing the RetryHttpHandler.
+ /// </summary>
+ private class MockHttpMessageHandler : HttpMessageHandler
+ {
+ private readonly HttpResponseMessage _defaultResponse;
+ private HttpResponseMessage? _responseAfterRetryCount;
+ private int _retryCountForResponse;
+
+ public int RequestCount { get; private set; }
+
+ public MockHttpMessageHandler(HttpResponseMessage defaultResponse)
+ {
+ _defaultResponse = defaultResponse;
+ }
+
+ public void SetResponseAfterRetryCount(int retryCount,
HttpResponseMessage response)
+ {
+ _retryCountForResponse = retryCount;
+ _responseAfterRetryCount = response;
+ }
+
+ protected override Task<HttpResponseMessage> SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ RequestCount++;
+
+ if (_responseAfterRetryCount != null && RequestCount >
_retryCountForResponse)
+ {
+ return Task.FromResult(_responseAfterRetryCount);
+ }
+
+ // Create a new response instance to avoid modifying the
original
+ var response = new HttpResponseMessage
+ {
+ StatusCode = _defaultResponse.StatusCode,
+ Content = _defaultResponse.Content
+ };
+
+ // Copy headers only if they exist
+ if (_defaultResponse.Headers.Contains("Retry-After"))
+ {
+ foreach (var value in
_defaultResponse.Headers.GetValues("Retry-After"))
+ {
+ response.Headers.Add("Retry-After", value);
+ }
+ }
+
+ return Task.FromResult(response);
+ }
+ }
+ }
+}