Skip to content

Commit 63007cc

Browse files
committed
fix: preserve trailer-read failure reason when handshake never completes
Sets failure to the InvalidOperationException so the auth error message reflects that the handshake did not complete, instead of falling back to the generic "no authorization token" message. Adds unit tests for the failure-description logic.
1 parent 4292879 commit 63007cc

2 files changed

Lines changed: 85 additions & 8 deletions

File tree

Spice/src/Flight/SpiceFlightClient.cs

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ private async Task AuthenticateAsync()
182182
// Trailers are only readable once the call has completed. Reading them
183183
// eagerly throws InvalidOperationException on a handshake that failed,
184184
// which masks the gRPC status that says what actually went wrong.
185-
RpcException? failure = null;
185+
Exception? failure = null;
186186
var token = headers.Get("authorization");
187187
if (token == null)
188188
{
@@ -191,9 +191,7 @@ private async Task AuthenticateAsync()
191191

192192
if (token == null || _httpClient == null)
193193
{
194-
var reason = failure == null
195-
? "the runtime returned no authorization token."
196-
: DescribeRpcFailure(failure);
194+
var reason = DescribeAuthFailure(failure);
197195

198196
throw new SpiceException(
199197
SpiceStatus.FailedToAuthenticate,
@@ -209,11 +207,11 @@ private async Task AuthenticateAsync()
209207
/// completed far enough for them to be available.
210208
/// </summary>
211209
/// <param name="stream">The handshake call</param>
212-
/// <param name="failure">The gRPC failure, when the trailers could not be read</param>
210+
/// <param name="failure">The failure that prevented reading the trailers, if any</param>
213211
/// <returns>The token entry, or null</returns>
214-
private static Metadata.Entry? TryGetTrailerToken(
212+
internal static Metadata.Entry? TryGetTrailerToken(
215213
AsyncDuplexStreamingCall<FlightHandshakeRequest, FlightHandshakeResponse> stream,
216-
out RpcException? failure)
214+
out Exception? failure)
217215
{
218216
failure = null;
219217
try
@@ -225,9 +223,10 @@ private async Task AuthenticateAsync()
225223
failure = ex;
226224
return null;
227225
}
228-
catch (InvalidOperationException)
226+
catch (InvalidOperationException ex)
229227
{
230228
// The handshake never completed, so there are no trailers to read.
229+
failure = ex;
231230
return null;
232231
}
233232
}
@@ -243,6 +242,21 @@ private static string DescribeRpcFailure(RpcException ex)
243242
return $"{ex.StatusCode} - {detail}.";
244243
}
245244

245+
/// <summary>
246+
/// Renders why the auth token could not be obtained as something a caller can act on.
247+
/// </summary>
248+
/// <param name="failure">The failure captured while trying to read the token, if any</param>
249+
/// <returns>A short description of the failure</returns>
250+
internal static string DescribeAuthFailure(Exception? failure)
251+
{
252+
return failure switch
253+
{
254+
null => "the runtime returned no authorization token.",
255+
RpcException rpcEx => DescribeRpcFailure(rpcEx),
256+
_ => "the handshake did not complete before trailers could be read."
257+
};
258+
}
259+
246260
internal async Task<FlightClientRecordBatchStreamReader> Query(string sql)
247261
{
248262
if (string.IsNullOrEmpty(sql))

SpiceTest/FlightAuthFailureTest.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
Copyright 2024 The Spice.ai OSS Authors
3+
4+
Permission is hereby granted, free of charge, to any person obtaining a copy
5+
of this software and associated documentation files (the "Software"), to deal
6+
in the Software without restriction, including without limitation the rights
7+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
copies of the Software, and to permit persons to whom the Software is
9+
furnished to do so, subject to the following conditions:
10+
11+
The above copyright notice and this permission notice shall be included in all
12+
copies or substantial portions of the Software.
13+
14+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
SOFTWARE.
21+
*/
22+
23+
using Grpc.Core;
24+
using NUnit.Framework;
25+
using Spice.Flight;
26+
27+
namespace SpiceTest;
28+
29+
/// <summary>
30+
/// Unit tests for how <see cref="SpiceFlightClient"/> describes why a handshake
31+
/// failed to yield an authorization token.
32+
/// </summary>
33+
[TestFixture]
34+
public class FlightAuthFailureTest
35+
{
36+
[Test]
37+
public void NoFailure_ReturnsNoTokenMessage()
38+
{
39+
var reason = SpiceFlightClient.DescribeAuthFailure(null);
40+
41+
Assert.That(reason, Is.EqualTo("the runtime returned no authorization token."));
42+
}
43+
44+
[Test]
45+
public void RpcFailure_DescribesStatusAndDetail()
46+
{
47+
var ex = new RpcException(new Status(StatusCode.Unauthenticated, "invalid api key"));
48+
49+
var reason = SpiceFlightClient.DescribeAuthFailure(ex);
50+
51+
Assert.That(reason, Is.EqualTo("Unauthenticated - invalid api key."));
52+
}
53+
54+
[Test]
55+
public void IncompleteHandshake_ReturnsIncompleteHandshakeMessage()
56+
{
57+
var ex = new InvalidOperationException("trailers are not available");
58+
59+
var reason = SpiceFlightClient.DescribeAuthFailure(ex);
60+
61+
Assert.That(reason, Is.EqualTo("the handshake did not complete before trailers could be read."));
62+
}
63+
}

0 commit comments

Comments
 (0)