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 7a1d18e8f feat(csharp/src/Drivers/Databricks): capture
x-thriftserver-error-message header (#3558)
7a1d18e8f is described below
commit 7a1d18e8f43e3ca5d228e73bcbf11917a1d4cdd7
Author: eric-wang-1990 <[email protected]>
AuthorDate: Tue Oct 14 06:30:02 2025 -0700
feat(csharp/src/Drivers/Databricks): capture x-thriftserver-error-message
header (#3558)
## Rationale for this change
When the Databricks ADBC C# driver encounters HTTP errors during Thrift
operations (e.g., 401 Unauthorized, 403 Forbidden), the specific error
message from the Databricks server is lost. The server includes detailed
error information in the `x-thriftserver-error-message` HTTP response
header, but currently only generic HTTP status messages reach users.
This makes debugging authentication and authorization issues difficult,
as users cannot distinguish between different failure causes (expired
token vs. invalid token vs. insufficient permissions).
## What changes are included in this PR?
- Add `ThriftErrorMessageHandler` as a new `DelegatingHandler` that
intercepts HTTP error responses
- Extract `x-thriftserver-error-message` header and include it in
exception messages
- Integrate handler into `DatabricksConnection` HTTP handler chain as
the innermost handler
- Add comprehensive unit tests covering 11 test scenarios
- Compatible with .NET Framework 4.7.2, .NET Standard 2.0, and .NET 8.0
## Are these changes tested?
Yes. Added `ThriftErrorMessageHandlerTest.cs` with 11 unit tests
covering:
- HTTP 401/403 with Thrift error messages
- Success responses (pass through unchanged)
- Error responses without header (pass through unchanged)
- Empty header values (ignored)
- Multiple HTTP status codes (400, 401, 403, 500, 503)
- Multiple header values (joined with commas)
All tests pass:
```
Test Run Successful.
Total tests: 11
Passed: 11
Total time: 0.6654 Seconds
```
Build verification also passed for all target frameworks.
## Are there any user-facing changes?
Yes - users will now see detailed error messages from Databricks instead
of generic HTTP status codes:
**Before:**
```
An unexpected error occurred while opening the session. 'Response status
code does not indicate success: 401 (Unauthorized).'
```
**After:**
```
An unexpected error occurred while opening the session. 'Thrift server
error: Invalid personal access token (HTTP 401 Unauthorized)'
```
This is a backward-compatible enhancement - if the header is not
present, behavior is unchanged.
Closes #3557
---
.../src/Drivers/Databricks/DatabricksConnection.cs | 4 +
.../Databricks/ThriftErrorMessageHandler.cs | 83 ++++++
.../Unit/ThriftErrorMessageHandlerTest.cs | 294 +++++++++++++++++++++
3 files changed, 381 insertions(+)
diff --git a/csharp/src/Drivers/Databricks/DatabricksConnection.cs
b/csharp/src/Drivers/Databricks/DatabricksConnection.cs
index eaed71e99..89603136b 100644
--- a/csharp/src/Drivers/Databricks/DatabricksConnection.cs
+++ b/csharp/src/Drivers/Databricks/DatabricksConnection.cs
@@ -554,6 +554,10 @@ namespace Apache.Arrow.Adbc.Drivers.Databricks
HttpMessageHandler baseHandler = base.CreateHttpHandler();
HttpMessageHandler baseAuthHandler =
HiveServer2TlsImpl.NewHttpClientHandler(TlsOptions, _proxyConfigurator);
+ // Add Thrift error message handler first (innermost) to capture
x-thriftserver-error-message headers
+ baseHandler = new ThriftErrorMessageHandler(baseHandler);
+ baseAuthHandler = new ThriftErrorMessageHandler(baseAuthHandler);
+
// Add tracing handler to propagate W3C trace context if enabled
if (_tracePropagationEnabled)
{
diff --git a/csharp/src/Drivers/Databricks/ThriftErrorMessageHandler.cs
b/csharp/src/Drivers/Databricks/ThriftErrorMessageHandler.cs
new file mode 100644
index 000000000..6afddc362
--- /dev/null
+++ b/csharp/src/Drivers/Databricks/ThriftErrorMessageHandler.cs
@@ -0,0 +1,83 @@
+/*
+ * 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.Linq;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Apache.Arrow.Adbc.Drivers.Databricks
+{
+ /// <summary>
+ /// HTTP handler that captures the x-thriftserver-error-message header
from error responses
+ /// and includes it in the exception message for better error visibility.
+ /// </summary>
+ internal class ThriftErrorMessageHandler : DelegatingHandler
+ {
+ private const string ThriftServerErrorMessageHeader =
"x-thriftserver-error-message";
+
+ /// <summary>
+ /// Initializes a new instance of the <see
cref="ThriftErrorMessageHandler"/> class.
+ /// </summary>
+ /// <param name="innerHandler">The inner handler to delegate
to.</param>
+ public ThriftErrorMessageHandler(HttpMessageHandler innerHandler)
+ : base(innerHandler)
+ {
+ }
+
+ /// <summary>
+ /// Sends an HTTP request and captures Thrift server error messages
from response headers.
+ /// </summary>
+ protected override async Task<HttpResponseMessage> SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ HttpResponseMessage response = await base.SendAsync(request,
cancellationToken);
+
+ // Check if the response indicates an error
+ if (!response.IsSuccessStatusCode)
+ {
+ // Try to extract the Thrift server error message header
+ if
(response.Headers.TryGetValues(ThriftServerErrorMessageHeader, out var
headerValues))
+ {
+ string thriftErrorMessage = string.Join(", ",
headerValues);
+
+ if (!string.IsNullOrWhiteSpace(thriftErrorMessage))
+ {
+ // Create a custom exception that includes both the
HTTP status and the Thrift error message
+ string errorMessage = $"Thrift server error:
{thriftErrorMessage} (HTTP {(int)response.StatusCode} {response.ReasonPhrase})";
+
+ // Capture the status code before disposing
+ var statusCode = response.StatusCode;
+
+ // Dispose the response before throwing
+ response.Dispose();
+
+#if NET5_0_OR_GREATER
+ throw new HttpRequestException(errorMessage, null,
statusCode);
+#else
+ throw new HttpRequestException(errorMessage);
+#endif
+ }
+ }
+ }
+
+ return response;
+ }
+ }
+}
diff --git
a/csharp/test/Drivers/Databricks/Unit/ThriftErrorMessageHandlerTest.cs
b/csharp/test/Drivers/Databricks/Unit/ThriftErrorMessageHandlerTest.cs
new file mode 100644
index 000000000..eba3aee2e
--- /dev/null
+++ b/csharp/test/Drivers/Databricks/Unit/ThriftErrorMessageHandlerTest.cs
@@ -0,0 +1,294 @@
+/*
+* 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 Apache.Arrow.Adbc.Drivers.Databricks;
+using Xunit;
+
+namespace Apache.Arrow.Adbc.Tests.Drivers.Databricks.Unit
+{
+ /// <summary>
+ /// Tests for the ThriftErrorMessageHandler class.
+ /// </summary>
+ public class ThriftErrorMessageHandlerTest
+ {
+ /// <summary>
+ /// Tests that the handler captures x-thriftserver-error-message
header from 401 responses.
+ /// </summary>
+ [Fact]
+ public async Task HandlerCapturesThriftErrorMessageFrom401Response()
+ {
+ // Create a mock handler that returns a 401 response with
x-thriftserver-error-message header
+ var mockHandler = new MockHttpMessageHandler(
+ HttpStatusCode.Unauthorized,
+ "Invalid personal access token");
+
+ // Create the ThriftErrorMessageHandler
+ var thriftHandler = new ThriftErrorMessageHandler(mockHandler);
+
+ // Create an HttpClient with our handler
+ var httpClient = new HttpClient(thriftHandler);
+
+ // Send a request and expect an HttpRequestException
+ var exception = await
Assert.ThrowsAsync<HttpRequestException>(async () =>
+ await httpClient.GetAsync("http://test.com"));
+
+ // Verify the exception message includes the Thrift error message
+ Assert.Contains("Thrift server error: Invalid personal access
token", exception.Message);
+ Assert.Contains("HTTP 401", exception.Message);
+ Assert.Contains("Unauthorized", exception.Message);
+ }
+
+ /// <summary>
+ /// Tests that the handler captures x-thriftserver-error-message
header from 403 responses.
+ /// </summary>
+ [Fact]
+ public async Task HandlerCapturesThriftErrorMessageFrom403Response()
+ {
+ // Create a mock handler that returns a 403 response with
x-thriftserver-error-message header
+ var mockHandler = new MockHttpMessageHandler(
+ HttpStatusCode.Forbidden,
+ "User does not have permission to access catalog 'main'");
+
+ // Create the ThriftErrorMessageHandler
+ var thriftHandler = new ThriftErrorMessageHandler(mockHandler);
+
+ // Create an HttpClient with our handler
+ var httpClient = new HttpClient(thriftHandler);
+
+ // Send a request and expect an HttpRequestException
+ var exception = await
Assert.ThrowsAsync<HttpRequestException>(async () =>
+ await httpClient.GetAsync("http://test.com"));
+
+ // Verify the exception message includes the Thrift error message
+ Assert.Contains("Thrift server error: User does not have
permission to access catalog 'main'", exception.Message);
+ Assert.Contains("HTTP 403", exception.Message);
+ Assert.Contains("Forbidden", exception.Message);
+ }
+
+ /// <summary>
+ /// Tests that the handler passes through success responses unchanged.
+ /// </summary>
+ [Fact]
+ public async Task HandlerPassesThroughSuccessResponses()
+ {
+ // Create a mock handler that returns a 200 OK response
+ var mockHandler = new MockHttpMessageHandler(HttpStatusCode.OK,
null);
+
+ // Create the ThriftErrorMessageHandler
+ var thriftHandler = new ThriftErrorMessageHandler(mockHandler);
+
+ // Create an HttpClient with our handler
+ var httpClient = new HttpClient(thriftHandler);
+
+ // Send a request
+ var response = await httpClient.GetAsync("http://test.com");
+
+ // Verify the response is successful and passes through unchanged
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("Success", await
response.Content.ReadAsStringAsync());
+ }
+
+ /// <summary>
+ /// Tests that the handler passes through error responses without
x-thriftserver-error-message header.
+ /// </summary>
+ [Fact]
+ public async Task
HandlerPassesThroughErrorResponsesWithoutThriftHeader()
+ {
+ // Create a mock handler that returns a 404 response without
x-thriftserver-error-message header
+ var mockHandler = new
MockHttpMessageHandler(HttpStatusCode.NotFound, null);
+
+ // Create the ThriftErrorMessageHandler
+ var thriftHandler = new ThriftErrorMessageHandler(mockHandler);
+
+ // Create an HttpClient with our handler
+ var httpClient = new HttpClient(thriftHandler);
+
+ // Send a request
+ var response = await httpClient.GetAsync("http://test.com");
+
+ // Verify the response is returned as-is (not thrown as exception)
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ Assert.Equal("Not Found", await
response.Content.ReadAsStringAsync());
+ }
+
+ /// <summary>
+ /// Tests that the handler ignores empty x-thriftserver-error-message
headers.
+ /// </summary>
+ [Fact]
+ public async Task HandlerIgnoresEmptyThriftErrorMessage()
+ {
+ // Create a mock handler that returns a 401 response with empty
x-thriftserver-error-message header
+ var mockHandler = new
MockHttpMessageHandler(HttpStatusCode.Unauthorized, "");
+
+ // Create the ThriftErrorMessageHandler
+ var thriftHandler = new ThriftErrorMessageHandler(mockHandler);
+
+ // Create an HttpClient with our handler
+ var httpClient = new HttpClient(thriftHandler);
+
+ // Send a request
+ var response = await httpClient.GetAsync("http://test.com");
+
+ // Verify the response is returned as-is (empty header is ignored)
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ /// <summary>
+ /// Tests that the handler captures Thrift error messages from various
HTTP error codes.
+ /// </summary>
+ [Theory]
+ [InlineData(HttpStatusCode.BadRequest, "Invalid request parameter:
'limit' must be positive", "Bad Request")]
+ [InlineData(HttpStatusCode.Unauthorized, "Token has expired",
"Unauthorized")]
+ [InlineData(HttpStatusCode.Forbidden, "Insufficient permissions",
"Forbidden")]
+ [InlineData(HttpStatusCode.InternalServerError, "Internal server error
occurred", "Internal Server Error")]
+ [InlineData(HttpStatusCode.ServiceUnavailable, "Service is temporarily
unavailable", "Service Unavailable")]
+ public async Task
HandlerCapturesThriftErrorMessagesFromVariousStatusCodes(
+ HttpStatusCode statusCode,
+ string thriftErrorMessage,
+ string reasonPhrase)
+ {
+ // Create a mock handler that returns the specified status code
with x-thriftserver-error-message header
+ var mockHandler = new MockHttpMessageHandler(statusCode,
thriftErrorMessage);
+
+ // Create the ThriftErrorMessageHandler
+ var thriftHandler = new ThriftErrorMessageHandler(mockHandler);
+
+ // Create an HttpClient with our handler
+ var httpClient = new HttpClient(thriftHandler);
+
+ // Send a request and expect an HttpRequestException
+ var exception = await
Assert.ThrowsAsync<HttpRequestException>(async () =>
+ await httpClient.GetAsync("http://test.com"));
+
+ // Verify the exception message includes the Thrift error message
+ Assert.Contains($"Thrift server error: {thriftErrorMessage}",
exception.Message);
+ Assert.Contains($"HTTP {(int)statusCode}", exception.Message);
+ Assert.Contains(reasonPhrase, exception.Message);
+ }
+
+ /// <summary>
+ /// Tests that the handler handles multiple values in
x-thriftserver-error-message header.
+ /// </summary>
+ [Fact]
+ public async Task HandlerHandlesMultipleThriftErrorMessageValues()
+ {
+ // Create a mock handler that returns a 401 response with multiple
x-thriftserver-error-message header values
+ var mockHandler = new MockHttpMessageHandlerMultipleHeaders(
+ HttpStatusCode.Unauthorized,
+ new[] { "Error 1", "Error 2" });
+
+ // Create the ThriftErrorMessageHandler
+ var thriftHandler = new ThriftErrorMessageHandler(mockHandler);
+
+ // Create an HttpClient with our handler
+ var httpClient = new HttpClient(thriftHandler);
+
+ // Send a request and expect an HttpRequestException
+ var exception = await
Assert.ThrowsAsync<HttpRequestException>(async () =>
+ await httpClient.GetAsync("http://test.com"));
+
+ // Verify the exception message includes both error messages joined
+ Assert.Contains("Thrift server error: Error 1, Error 2",
exception.Message);
+ }
+
+ /// <summary>
+ /// Mock HttpMessageHandler for testing the ThriftErrorMessageHandler.
+ /// </summary>
+ private class MockHttpMessageHandler : HttpMessageHandler
+ {
+ private readonly HttpStatusCode _statusCode;
+ private readonly string? _thriftErrorMessage;
+
+ public MockHttpMessageHandler(HttpStatusCode statusCode, string?
thriftErrorMessage)
+ {
+ _statusCode = statusCode;
+ _thriftErrorMessage = thriftErrorMessage;
+ }
+
+ protected override Task<HttpResponseMessage> SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ var response = new HttpResponseMessage(_statusCode);
+
+ if (_statusCode == HttpStatusCode.OK)
+ {
+ response.Content = new StringContent("Success");
+ }
+ else if (_statusCode == HttpStatusCode.NotFound)
+ {
+ response.Content = new StringContent("Not Found");
+ }
+ else
+ {
+ response.Content = new StringContent($"Error:
{_statusCode}");
+ }
+
+ // Add x-thriftserver-error-message header if provided
+ if (!string.IsNullOrEmpty(_thriftErrorMessage))
+ {
+
response.Headers.TryAddWithoutValidation("x-thriftserver-error-message",
_thriftErrorMessage);
+ }
+ else if (_thriftErrorMessage == "")
+ {
+ // Explicitly add empty header for testing empty header
scenario
+
response.Headers.TryAddWithoutValidation("x-thriftserver-error-message", "");
+ }
+
+ return Task.FromResult(response);
+ }
+ }
+
+ /// <summary>
+ /// Mock HttpMessageHandler that supports multiple header values for
testing.
+ /// </summary>
+ private class MockHttpMessageHandlerMultipleHeaders :
HttpMessageHandler
+ {
+ private readonly HttpStatusCode _statusCode;
+ private readonly string[] _thriftErrorMessages;
+
+ public MockHttpMessageHandlerMultipleHeaders(HttpStatusCode
statusCode, string[] thriftErrorMessages)
+ {
+ _statusCode = statusCode;
+ _thriftErrorMessages = thriftErrorMessages;
+ }
+
+ protected override Task<HttpResponseMessage> SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ var response = new HttpResponseMessage(_statusCode)
+ {
+ Content = new StringContent($"Error: {_statusCode}")
+ };
+
+ // Add multiple x-thriftserver-error-message header values
+ foreach (var errorMessage in _thriftErrorMessages)
+ {
+
response.Headers.TryAddWithoutValidation("x-thriftserver-error-message",
errorMessage);
+ }
+
+ return Task.FromResult(response);
+ }
+ }
+ }
+}