blob: fdbb7671b736caf861536b56a8bb2aa1f88ec209 [file]
/*
* 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.Unit
{
/// <summary>
/// Tests for the RetryHttpHandler class.
///
/// IMPORTANT: These tests verify retry behavior in isolation. In production, RetryHttpHandler
/// must be positioned INSIDE (closer to network) ThriftErrorMessageHandler in the handler chain
/// so that retries happen before exceptions are thrown. See DatabricksConnection.CreateHttpHandler()
/// for the correct handler chain ordering and detailed explanation.
/// </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, 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, 1);
// Create an HttpClient with our handler
var httpClient = new HttpClient(retryHandler);
// Send a request and expect an AdbcException
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 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-retryable responses correctly.
/// </summary>
[Fact]
public async Task RetryAfterHandlerHandlesNonRetryableResponse()
{
// 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, 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 using exponential backoff.
/// </summary>
[Fact]
public async Task RetryHandlerUsesExponentialBackoffFor503WithoutRetryAfterHeader()
{
// 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, 5);
// 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 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 by using exponential backoff.
/// </summary>
[Fact]
public async Task RetryHandlerUsesExponentialBackoffForInvalidRetryAfterHeader()
{
// 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);
// 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, 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 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, 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, 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, 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>
/// Tests that the RetryHttpHandler properly handles HTTP TooManyRequests (429) responses with separate timeout.
/// </summary>
[Fact]
public async Task RetryHandlerHandlesRateLimitWithSeparateTimeout()
{
// Create a mock handler that returns a TooManyRequests (429) response with a Retry-After header
var mockHandler = new MockHttpMessageHandler(
new HttpResponseMessage((HttpStatusCode)429)
{
Headers = { { "Retry-After", "1" } },
Content = new StringContent("Too Many Requests")
});
// Create the RetryHttpHandler with different timeouts: 900s for ServiceUnavailable, 2s for TooManyRequests
var retryHandler = new RetryHttpHandler(mockHandler, 900, 2);
// 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 respects the rate limit timeout for TooManyRequests (429) responses.
/// </summary>
[Fact]
public async Task RetryHandlerRespectsRateLimitTimeout()
{
// Create a mock handler that always returns a TooManyRequests (429) response with a Retry-After header
var mockHandler = new MockHttpMessageHandler(
new HttpResponseMessage((HttpStatusCode)429)
{
Headers = { { "Retry-After", "2" } },
Content = new StringContent("Too Many Requests")
});
// Create the RetryHttpHandler with different timeouts: 900s for ServiceUnavailable, 1s for TooManyRequests
var retryHandler = new RetryHttpHandler(mockHandler, 900, 1);
// 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 only tried once (since the Retry-After value of 2 exceeds our TooManyRequests timeout of 1)
Assert.Equal(1, mockHandler.RequestCount);
}
/// <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);
}
}
}
}