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);
+            }
+        }
+    }
+}

Reply via email to