Skip to content

Commit 28f6336

Browse files
feat: Add Spanner request ID in exceptions
1 parent 1c13d3a commit 28f6336

File tree

3 files changed

+527
-4
lines changed

3 files changed

+527
-4
lines changed
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
using Grpc.Core;
16+
using Grpc.Core.Interceptors;
17+
using System;
18+
using System.Threading;
19+
using System.Threading.Tasks;
20+
using Xunit;
21+
22+
namespace Google.Cloud.Spanner.V1.Tests;
23+
24+
public class RequestIdOnExceptionInterceptorTests
25+
{
26+
private static readonly SpannerClientBuilder.RequestIdOnExceptionInterceptor s_interceptor =
27+
SpannerClientBuilder.RequestIdOnExceptionInterceptor.Instance;
28+
29+
private readonly string _fakeRequest = "fake";
30+
private readonly ClientInterceptorContext<string, string> _unaryContext = CreateContext(MethodType.ClientStreaming);
31+
private readonly ClientInterceptorContext<string, string> _clientStreamingContext = CreateContext(MethodType.ClientStreaming);
32+
private readonly ClientInterceptorContext<string, string> _serverStreamingContext = CreateContext(MethodType.ClientStreaming);
33+
private readonly ClientInterceptorContext<string, string> _duplexStreamingContext = CreateContext(MethodType.ClientStreaming);
34+
35+
private static readonly string s_requestId = Guid.NewGuid().ToString();
36+
private static readonly Metadata s_metadata = new() { { "x-goog-spanner-request-id", s_requestId } };
37+
private static readonly CallOptions s_options = new(headers: s_metadata);
38+
private static readonly string s_sampleSessionName = "sessionName";
39+
40+
private static readonly Exception s_exception = new();
41+
42+
[Fact]
43+
public void BlockingUnaryCall_ContinuationThrows_ExceptionEnriched() =>
44+
AssertThrowsEnrichedException(() =>
45+
s_interceptor.BlockingUnaryCall(_fakeRequest, _unaryContext, (req, ctx) => throw s_exception));
46+
47+
[Fact]
48+
public void AsyncUnaryCall_ContinuationThrows_ExceptionEnriched() =>
49+
AssertThrowsEnrichedException(() =>
50+
s_interceptor.AsyncUnaryCall(_fakeRequest, _unaryContext, (req, ctx) => throw s_exception));
51+
52+
[Fact]
53+
public void AsyncClientStreamingCall_ContinuationThrows_ExceptionEnriched() =>
54+
AssertThrowsEnrichedException(() =>
55+
s_interceptor.AsyncClientStreamingCall(_clientStreamingContext, (ctx) => throw s_exception));
56+
57+
[Fact]
58+
public void AsyncServerStreamingCall_ContinuationThrows_ExceptionEnriched() =>
59+
AssertThrowsEnrichedException(() =>
60+
s_interceptor.AsyncServerStreamingCall(_fakeRequest, _serverStreamingContext, (req, ctx) => throw s_exception));
61+
62+
[Fact]
63+
public void AsyncDuplexStreamingCall_ContinuationThrows_ExceptionEnriched() =>
64+
AssertThrowsEnrichedException(() =>
65+
s_interceptor.AsyncDuplexStreamingCall(_duplexStreamingContext, (ctx) => throw s_exception));
66+
67+
[Fact]
68+
public async Task AsyncUnaryCall_ResponseAsyncThrows_ExceptionEnriched()
69+
{
70+
var call = CreateAsyncUnaryCall(response: Task.FromException<string>(s_exception));
71+
await AssertThrowsEnrichedExceptionAsync(() =>
72+
s_interceptor.AsyncUnaryCall(_fakeRequest, _unaryContext, (req, ctx) => call).ResponseAsync);
73+
}
74+
75+
[Fact]
76+
public async Task AsyncUnaryCall_ResponseHeadersAsyncThrows_ExceptionEnriched()
77+
{
78+
var call = CreateAsyncUnaryCall(headers: Task.FromException<Metadata>(s_exception));
79+
await AssertThrowsEnrichedExceptionAsync(() =>
80+
s_interceptor.AsyncUnaryCall(_fakeRequest, _unaryContext, (req, ctx) => call).ResponseHeadersAsync);
81+
}
82+
83+
[Fact]
84+
public async Task AsyncClientStreamingCall_ResponseAsyncThrows_ExceptionEnriched()
85+
{
86+
var call = CreateAsyncClientStreamingCall(response: Task.FromException<string>(s_exception));
87+
await AssertThrowsEnrichedExceptionAsync(() =>
88+
s_interceptor.AsyncClientStreamingCall(_clientStreamingContext, (ctx) => call).ResponseAsync);
89+
}
90+
91+
[Fact]
92+
public async Task AsyncClientStreamingCall_ResponseHeadersAsyncThrows_ExceptionEnriched()
93+
{
94+
var call = CreateAsyncClientStreamingCall(headers: Task.FromException<Metadata>(s_exception));
95+
await AssertThrowsEnrichedExceptionAsync(() =>
96+
s_interceptor.AsyncClientStreamingCall(_clientStreamingContext, (ctx) => call).ResponseHeadersAsync);
97+
}
98+
99+
[Fact]
100+
public async Task AsyncClientStreamingCall_RequestStreamWriteThrows_ExceptionEnriched()
101+
{
102+
var call = CreateAsyncClientStreamingCall(requestStream: new ThrowingClientStreamWriter<string>(s_exception));
103+
await AssertThrowsEnrichedExceptionAsync(() =>
104+
s_interceptor.AsyncClientStreamingCall(_clientStreamingContext, (ctx) => call).RequestStream.WriteAsync("1"));
105+
}
106+
107+
[Fact]
108+
public async Task AsyncClientStreamingCall_RequestStreamCompleteThrows_ExceptionEnriched()
109+
{
110+
var call = CreateAsyncClientStreamingCall(requestStream: new ThrowingClientStreamWriter<string>(s_exception));
111+
var context = CreateContext(MethodType.ClientStreaming);
112+
await AssertThrowsEnrichedExceptionAsync(() =>
113+
s_interceptor.AsyncClientStreamingCall(_clientStreamingContext, (ctx) => call).RequestStream.CompleteAsync());
114+
}
115+
116+
[Fact]
117+
public async Task AsyncServerStreamingCall_ResponseHeadersAsyncThrows_ExceptionEnriched()
118+
{
119+
var call = CreateAsyncServerStreamingCall(headers: Task.FromException<Metadata>(s_exception));
120+
await AssertThrowsEnrichedExceptionAsync(() =>
121+
s_interceptor.AsyncServerStreamingCall("1", _serverStreamingContext, (req, ctx) => call).ResponseHeadersAsync);
122+
}
123+
124+
[Fact]
125+
public async Task AsyncServerStreamingCall_ResponseStreamMoveNextThrows_ExceptionEnriched()
126+
{
127+
var call = CreateAsyncServerStreamingCall(responseStream: new ThrowingAsyncStreamReader<string>(s_exception));
128+
await AssertThrowsEnrichedExceptionAsync(() =>
129+
s_interceptor.AsyncServerStreamingCall(_fakeRequest, _serverStreamingContext, (req, ctx) => call).ResponseStream.MoveNext(default));
130+
}
131+
132+
[Fact]
133+
public async Task AsyncDuplexStreamingCall_ResponseHeadersAsyncThrows_ExceptionEnriched()
134+
{
135+
var call = CreateAsyncDuplexStreamingCall(headers: Task.FromException<Metadata>(s_exception));
136+
await AssertThrowsEnrichedExceptionAsync(() =>
137+
s_interceptor.AsyncDuplexStreamingCall(_duplexStreamingContext, (ctx) => call).ResponseHeadersAsync);
138+
}
139+
140+
[Fact]
141+
public async Task AsyncDuplexStreamingCall_RequestStreamWriteThrows_ExceptionEnriched()
142+
{
143+
var call = CreateAsyncDuplexStreamingCall(requestStream: new ThrowingClientStreamWriter<string>(s_exception));
144+
await AssertThrowsEnrichedExceptionAsync(() =>
145+
s_interceptor.AsyncDuplexStreamingCall(_duplexStreamingContext, (ctx) => call).RequestStream.WriteAsync("1"));
146+
}
147+
148+
[Fact]
149+
public async Task AsyncDuplexStreamingCall_RequestStreamCompleteThrows_ExceptionEnriched()
150+
{
151+
var call = CreateAsyncDuplexStreamingCall(requestStream: new ThrowingClientStreamWriter<string>(s_exception));
152+
await AssertThrowsEnrichedExceptionAsync(() =>
153+
s_interceptor.AsyncDuplexStreamingCall(_duplexStreamingContext, (ctx) => call).RequestStream.CompleteAsync());
154+
}
155+
156+
[Fact]
157+
public async Task AsyncDuplexStreamingCall_ResponseStreamMoveNextThrows_ExceptionEnriched()
158+
{
159+
var call = CreateAsyncDuplexStreamingCall(responseStream: new ThrowingAsyncStreamReader<string>(s_exception));
160+
await AssertThrowsEnrichedExceptionAsync(() =>
161+
s_interceptor.AsyncDuplexStreamingCall(_duplexStreamingContext, (ctx) => call).ResponseStream.MoveNext(default));
162+
}
163+
164+
/// <summary>
165+
/// This test validates when a gRPC error is thrown while using the <see cref="SpannerClient"/>
166+
/// the exception is enriched with the RequestId. We do not cover the full set of exception flows
167+
/// because <see cref="SpannerClient"/> does not implement all gRPC call types (i.e. no DuplexStreaming
168+
/// and ClientStreaming) and we have already covered all cases with direct unit tests on
169+
/// <see cref="SpannerClientBuilder.RequestIdOnExceptionInterceptor"/>. This test serves to validate
170+
/// <see cref="SpannerClientBuilder"/> attaches the interceptor on build.
171+
/// </summary>
172+
[Fact]
173+
public async Task SpannerClient_Throws_ExceptionEnriched()
174+
{
175+
var callInvoker = new FakeThrowingCallInvoker(s_exception);
176+
var client = new SpannerClientBuilder { CallInvoker = callInvoker }.Build();
177+
178+
var stream = client.ExecuteStreamingSql(new ExecuteSqlRequest { Session = s_sampleSessionName, Sql = "SELECT 1" });
179+
await AssertThrowsEnrichedExceptionAsync(async () => await stream.GrpcCall.ResponseStream.MoveNext(default));
180+
}
181+
182+
// Verification Helpers
183+
184+
private static void AssertThrowsEnrichedException(Action action) =>
185+
AssertEnrichedException(Assert.Throws<Exception>(action));
186+
187+
private static async Task AssertThrowsEnrichedExceptionAsync(Func<Task> action) =>
188+
AssertEnrichedException(await Assert.ThrowsAsync<Exception>(action));
189+
190+
private static void AssertEnrichedException(Exception ex)
191+
{
192+
// The Exception.Data property should contain a non-empty Request ID field
193+
Assert.Same(s_exception, ex);
194+
Assert.True(s_exception.Data.Contains("x-goog-spanner-request-id"));
195+
Assert.False(string.IsNullOrEmpty((string)s_exception.Data["x-goog-spanner-request-id"]));
196+
}
197+
198+
// Creation Helpers
199+
200+
private static ClientInterceptorContext<string, string> CreateContext(MethodType methodType) =>
201+
new ClientInterceptorContext<string, string>(
202+
new Method<string, string>(methodType, "s", "m", Marshallers.StringMarshaller, Marshallers.StringMarshaller),
203+
null,
204+
s_options);
205+
206+
private static AsyncUnaryCall<string> CreateAsyncUnaryCall(Task<string> response = null, Task<Metadata> headers = null) =>
207+
new AsyncUnaryCall<string>(
208+
response ?? Task.FromResult("1"),
209+
headers ?? Task.FromResult(new Metadata()),
210+
() => Status.DefaultSuccess,
211+
() => new Metadata(),
212+
() => { });
213+
214+
private static AsyncClientStreamingCall<string, string> CreateAsyncClientStreamingCall(
215+
IClientStreamWriter<string> requestStream = null,
216+
Task<string> response = null,
217+
Task<Metadata> headers = null) =>
218+
new AsyncClientStreamingCall<string, string>(
219+
requestStream ?? new ThrowingClientStreamWriter<string>(null),
220+
response ?? Task.FromResult("1"),
221+
headers ?? Task.FromResult(new Metadata()),
222+
() => Status.DefaultSuccess,
223+
() => new Metadata(),
224+
() => { });
225+
226+
private static AsyncServerStreamingCall<string> CreateAsyncServerStreamingCall(
227+
IAsyncStreamReader<string> responseStream = null,
228+
Task<Metadata> headers = null) =>
229+
new AsyncServerStreamingCall<string>(
230+
responseStream ?? new ThrowingAsyncStreamReader<string>(null),
231+
headers ?? Task.FromResult(new Metadata()),
232+
() => Status.DefaultSuccess,
233+
() => new Metadata(),
234+
() => { });
235+
236+
private static AsyncDuplexStreamingCall<string, string> CreateAsyncDuplexStreamingCall(
237+
IClientStreamWriter<string> requestStream = null,
238+
IAsyncStreamReader<string> responseStream = null,
239+
Task<Metadata> headers = null) =>
240+
new AsyncDuplexStreamingCall<string, string>(
241+
requestStream ?? new ThrowingClientStreamWriter<string>(null),
242+
responseStream ?? new ThrowingAsyncStreamReader<string>(null),
243+
headers ?? Task.FromResult(new Metadata()),
244+
() => Status.DefaultSuccess,
245+
() => new Metadata(),
246+
() => { });
247+
248+
private class ThrowingAsyncStreamReader<T> : IAsyncStreamReader<T>
249+
{
250+
private readonly Exception _exception;
251+
public ThrowingAsyncStreamReader(Exception exception) => _exception = exception;
252+
public T Current => default;
253+
public Task<bool> MoveNext(CancellationToken cancellationToken) =>
254+
_exception != null ? Task.FromException<bool>(_exception) : Task.FromResult(false);
255+
}
256+
257+
private class ThrowingClientStreamWriter<T> : IClientStreamWriter<T>
258+
{
259+
private readonly Exception _exception;
260+
public ThrowingClientStreamWriter(Exception exception) => _exception = exception;
261+
public WriteOptions WriteOptions { get; set; }
262+
public Task CompleteAsync() => _exception != null ? Task.FromException(_exception) : Task.CompletedTask;
263+
public Task WriteAsync(T message) => _exception != null ? Task.FromException(_exception) : Task.CompletedTask;
264+
}
265+
266+
private class FakeThrowingCallInvoker : CallInvoker
267+
{
268+
private readonly Exception _exceptionToThrow;
269+
270+
public FakeThrowingCallInvoker(Exception exceptionToThrow)
271+
{
272+
_exceptionToThrow = exceptionToThrow;
273+
}
274+
275+
public override AsyncServerStreamingCall<TResponse> AsyncServerStreamingCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options, TRequest request)
276+
{
277+
return new AsyncServerStreamingCall<TResponse>(
278+
new ThrowingAsyncStreamReader<TResponse>(_exceptionToThrow),
279+
Task.FromException<Metadata>(_exceptionToThrow),
280+
() => Status.DefaultSuccess,
281+
() => new Metadata(),
282+
() => { });
283+
}
284+
285+
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options, TRequest request) => throw new NotImplementedException();
286+
public override AsyncClientStreamingCall<TRequest, TResponse> AsyncClientStreamingCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options) => throw new NotImplementedException();
287+
public override AsyncDuplexStreamingCall<TRequest, TResponse> AsyncDuplexStreamingCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options) => throw new NotImplementedException();
288+
public override TResponse BlockingUnaryCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options, TRequest request) => throw new NotImplementedException();
289+
}
290+
}

0 commit comments

Comments
 (0)