b9096f7c57
ExecuteSafeUnaryAsync capped the whole retry pipeline at one DefaultCallTimeout while each attempt carried the same deadline, so a first attempt ending in DeadlineExceeded spent the entire budget and the retry never ran. The outer cap is now MaxAttempts x DefaultCallTimeout plus MaxDelay per backoff (MxGatewayClientRetryPolicy.SafeUnaryBudget), in both MxGatewayClient and GalaxyRepositoryClient; a budget too large for a cancellation timer leaves only the per-attempt deadlines. Tests: MxGatewayClientWireTests.SafeUnaryRetry_AfterDeadlineExceededFirstAttempt_RunsTheRetry (real gRPC loopback server stalls the first Invoke; fails with Cancelled on the old code), plus two SafeUnaryBudget unit tests.
198 lines
8.2 KiB
C#
198 lines
8.2 KiB
C#
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
|
|
|
/// <summary>
|
|
/// Drives the public client API against <see cref="WireFakeGatewayServer"/> — a real
|
|
/// gRPC server on loopback — so the transport, protobuf serialization, call metadata,
|
|
/// and gRPC status mapping are all exercised. Every other test in this project
|
|
/// substitutes <see cref="FakeGatewayTransport"/> and therefore proves nothing about
|
|
/// what actually crosses the wire.
|
|
/// </summary>
|
|
public sealed class MxGatewayClientWireTests
|
|
{
|
|
private const string ApiKey = "mxgw_wiretest_secret";
|
|
|
|
/// <summary>
|
|
/// Verifies the full session happy path decodes real wire bytes end to end.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task SessionRoundTrip_OverRealTransport_DecodesEveryReplyField()
|
|
{
|
|
await using WireFakeGatewayServer server = await WireFakeGatewayServer.StartAsync();
|
|
await using MxGatewayClient client = server.CreateClient(ApiKey);
|
|
|
|
MxGatewaySession session = await client.OpenSessionAsync(
|
|
new OpenSessionRequest { ClientSessionName = "wire-test" });
|
|
|
|
Assert.Equal(WireFakeGatewayServer.FakeGatewayService.SessionId, session.SessionId);
|
|
Assert.Equal("fake-backend", session.OpenSessionReply.BackendName);
|
|
Assert.Equal(1234, session.OpenSessionReply.WorkerProcessId);
|
|
Assert.Equal(3u, session.OpenSessionReply.GatewayProtocolVersion);
|
|
Assert.Equal(["events", "invoke"], session.OpenSessionReply.Capabilities);
|
|
|
|
int serverHandle = await session.RegisterAsync("wire-test-client");
|
|
Assert.Equal(WireFakeGatewayServer.FakeGatewayService.ServerHandle, serverHandle);
|
|
|
|
MxCommandRequest? invoke = server.Service.InvokeRequest;
|
|
Assert.NotNull(invoke);
|
|
Assert.Equal(MxCommandKind.Register, invoke.Command.Kind);
|
|
Assert.Equal("wire-test-client", invoke.Command.Register.ClientName);
|
|
|
|
List<MxEvent> events = await CollectAsync(client.StreamEventsAsync(
|
|
new StreamEventsRequest { SessionId = session.SessionId }));
|
|
|
|
MxEvent single = Assert.Single(events);
|
|
Assert.Equal(MxEventFamily.OnDataChange, single.Family);
|
|
Assert.Equal(WireFakeGatewayServer.FakeGatewayService.ServerHandle, single.ServerHandle);
|
|
Assert.Equal(WireFakeGatewayServer.FakeGatewayService.ItemHandle, single.ItemHandle);
|
|
Assert.Equal(17, single.Value.Int32Value);
|
|
Assert.Equal(192, single.Quality);
|
|
Assert.Equal(9ul, single.WorkerSequence);
|
|
Assert.Equal(MxEvent.BodyOneofCase.OnDataChange, single.BodyCase);
|
|
|
|
CloseSessionReply closeReply = await session.CloseAsync();
|
|
Assert.Equal(SessionState.Closed, closeReply.FinalState);
|
|
Assert.Equal(
|
|
WireFakeGatewayServer.FakeGatewayService.SessionId,
|
|
server.Service.CloseSessionRequest?.SessionId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies the API key reaches the server as a bearer header on unary and
|
|
/// streaming calls alike. A transport fake can only assert what the client passes;
|
|
/// this asserts what the server receives.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task ApiKey_ReachesTheServerAsBearerMetadata_OnEveryRpc()
|
|
{
|
|
await using WireFakeGatewayServer server = await WireFakeGatewayServer.StartAsync();
|
|
await using MxGatewayClient client = server.CreateClient(ApiKey);
|
|
|
|
MxGatewaySession session = await client.OpenSessionAsync(
|
|
new OpenSessionRequest { ClientSessionName = "wire-test" });
|
|
await session.RegisterAsync("wire-test-client");
|
|
await CollectAsync(client.StreamEventsAsync(
|
|
new StreamEventsRequest { SessionId = session.SessionId }));
|
|
await session.CloseAsync();
|
|
|
|
string expected = $"Bearer {ApiKey}";
|
|
Assert.Equal(
|
|
new Dictionary<string, string>
|
|
{
|
|
["OpenSession"] = expected,
|
|
["Invoke"] = expected,
|
|
["StreamEvents"] = expected,
|
|
["CloseSession"] = expected,
|
|
},
|
|
server.Service.AuthorizationByMethod);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A safe unary call whose first attempt runs into its deadline must still get its retry:
|
|
/// the outer budget covers every attempt, not just one DefaultCallTimeout (#197).
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task SafeUnaryRetry_AfterDeadlineExceededFirstAttempt_RunsTheRetry()
|
|
{
|
|
await using WireFakeGatewayServer server = await WireFakeGatewayServer.StartAsync(service =>
|
|
service.StallFirstInvokes = 1);
|
|
await using MxGatewayClient client = server.CreateClient(
|
|
ApiKey,
|
|
TimeSpan.FromMilliseconds(500),
|
|
new MxGatewayClientRetryOptions
|
|
{
|
|
MaxAttempts = 2,
|
|
Delay = TimeSpan.FromMilliseconds(10),
|
|
MaxDelay = TimeSpan.FromMilliseconds(10),
|
|
UseJitter = false,
|
|
});
|
|
|
|
MxCommandReply reply = await client.InvokeAsync(new MxCommandRequest
|
|
{
|
|
SessionId = WireFakeGatewayServer.FakeGatewayService.SessionId,
|
|
Command = new MxCommand
|
|
{
|
|
Kind = MxCommandKind.Ping,
|
|
Ping = new PingCommand(),
|
|
},
|
|
});
|
|
|
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
|
Assert.Equal(2, server.Service.InvokeCount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies the gateway's replay-gap sentinel survives serialization and is
|
|
/// surfaced as a typed, non-terminal stream item.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task ReplayGapSentinel_SurvivesTheWire_AsTypedStreamItem()
|
|
{
|
|
await using WireFakeGatewayServer server = await WireFakeGatewayServer.StartAsync(service =>
|
|
service.ReplayGap = new ReplayGap
|
|
{
|
|
RequestedAfterSequence = 3,
|
|
OldestAvailableSequence = 8,
|
|
});
|
|
await using MxGatewayClient client = server.CreateClient(ApiKey);
|
|
|
|
MxGatewaySession session = await client.OpenSessionAsync(
|
|
new OpenSessionRequest { ClientSessionName = "wire-test" });
|
|
|
|
List<MxEventStreamItem> items = [];
|
|
IAsyncEnumerable<MxEvent> stream = client.StreamEventsAsync(new StreamEventsRequest
|
|
{
|
|
SessionId = session.SessionId,
|
|
AfterWorkerSequence = 3,
|
|
});
|
|
|
|
await foreach (MxEventStreamItem item in stream.AsStreamItemsAsync())
|
|
{
|
|
items.Add(item);
|
|
}
|
|
|
|
Assert.Equal(2, items.Count);
|
|
Assert.True(items[0].IsReplayGap);
|
|
Assert.Equal(3ul, items[0].ReplayGap!.RequestedAfterSequence);
|
|
Assert.Equal(8ul, items[0].ReplayGap!.OldestAvailableSequence);
|
|
Assert.False(items[1].IsReplayGap);
|
|
Assert.Equal(MxEventFamily.OnDataChange, items[1].Event.Family);
|
|
Assert.Equal(3ul, server.Service.StreamEventsRequest?.AfterWorkerSequence);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies a genuine <c>PERMISSION_DENIED</c> status maps to the typed client
|
|
/// exception rather than a bare RpcException.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task PermissionDeniedStatus_MapsToAuthorizationException()
|
|
{
|
|
await using WireFakeGatewayServer server = await WireFakeGatewayServer.StartAsync(
|
|
service => service.DenyInvoke = true);
|
|
await using MxGatewayClient client = server.CreateClient(ApiKey);
|
|
|
|
MxGatewaySession session = await client.OpenSessionAsync(
|
|
new OpenSessionRequest { ClientSessionName = "wire-test" });
|
|
|
|
await Assert.ThrowsAsync<MxGatewayAuthorizationException>(
|
|
() => session.RegisterAsync("wire-test-client"));
|
|
}
|
|
|
|
private static async Task<List<MxEvent>> CollectAsync(IAsyncEnumerable<MxEvent> stream)
|
|
{
|
|
List<MxEvent> events = [];
|
|
await foreach (MxEvent gatewayEvent in stream)
|
|
{
|
|
events.Add(gatewayEvent);
|
|
}
|
|
|
|
return events;
|
|
}
|
|
}
|