feat(client): standardize connection outcomes (#13)
quality-gate / quality (push) Successful in 59s

This commit is contained in:
KyuubiYoru
2026-07-16 10:18:41 +02:00
parent b4b6072fe1
commit 94aba8a3bb
47 changed files with 2727 additions and 178 deletions
@@ -83,6 +83,110 @@ public sealed class RendezvousJoinClientTests
Assert.Contains("cursor=next%20page%2Bcursor", handler.Requests[1].Uri.Query, StringComparison.Ordinal);
}
[Fact]
public async Task OutcomeReportingUsesTheAttemptCapabilityAndCoarseElapsedBucket()
{
CreateJoinAttemptResponse attempt = CreateAttempt();
RecordingHandler handler = new(JsonResponse(HttpStatusCode.OK, new ReportConnectionOutcomeResponse
{
Accepted = true,
IsDuplicate = false,
}));
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousJoinClient client = new(http);
RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.Create(
ConnectionOutcomeKind.DirectConnectTimedOut,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.DirectConnection,
RendezvousConnectionPhase.DirectConnection,
TimeSpan.FromSeconds(6));
RendezvousClientResult<ReportConnectionOutcomeResponse> result =
await client.ReportOutcomeAsync(attempt, outcome);
Assert.True(result.IsSuccess, result.Message);
RecordedRequest request = Assert.Single(handler.Requests);
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal(
attempt.ClientPunchCapability,
request.Headers["X-Rendezvous-Client-Punch-Capability"]);
Assert.Contains("\"outcome\":\"directConnectTimedOut\"", request.Body, StringComparison.Ordinal);
Assert.Contains("\"elapsedBucket\":\"fiveToFifteenSeconds\"", request.Body, StringComparison.Ordinal);
Assert.DoesNotContain("diagnostic", request.Body, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ConnectionStartReturnsATypedServiceOutcomeInsteadOfAnUnboundedFailure()
{
RecordingHandler handler = new(JsonResponse(HttpStatusCode.NotFound, new ApiError
{
Code = RendezvousErrorCode.NotFound,
Message = "listing unavailable",
}));
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousJoinClient client = new(http);
NetworkEndpoint fallback = new()
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "203.0.113.93",
Port = 9_063,
};
RendezvousConnectionStartResult result = await client.CreateConnectionAttemptAsync(
new CreateJoinAttemptRequest
{
IdempotencyKey = "typed-start",
GameId = new("space-game"),
EnvironmentId = new("production"),
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000230")),
ProtocolVersion = 7,
},
fallback);
Assert.True(result.IsCompleted);
Assert.False(result.IsReadyForTraversal);
Assert.Null(result.Attempt);
Assert.Equal(ConnectionOutcomeKind.DirectoryNotFound, result.Outcome!.Kind);
Assert.Equal("203.0.113.93", result.Outcome.DedicatedFallback!.Address);
}
[Fact]
public async Task ConnectionStartReturnsCancelledForAPrecancelledCallerToken()
{
RecordingHandler handler = new();
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousJoinClient client = new(http);
using CancellationTokenSource cancellation = new();
cancellation.Cancel();
RendezvousConnectionStartResult result = await client.CreateConnectionAttemptAsync(
CreateRequest("cancelled-before-send"),
cancellationToken: cancellation.Token);
Assert.Empty(handler.Requests);
Assert.Equal(ConnectionOutcomeKind.Cancelled, result.Outcome!.Kind);
Assert.Equal(RendezvousConnectionOutcomeSource.Caller, result.Outcome.Source);
}
[Fact]
public async Task ConnectionStartReturnsCancelledWhenCallerStopsASilentRequest()
{
CancellingHandler handler = new();
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousJoinClient client = new(http);
using CancellationTokenSource cancellation = new();
Task<RendezvousConnectionStartResult> pending = client.CreateConnectionAttemptAsync(
CreateRequest("cancelled-in-flight"),
cancellationToken: cancellation.Token);
await handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
await cancellation.CancelAsync();
RendezvousConnectionStartResult result = await pending;
Assert.Equal(ConnectionOutcomeKind.Cancelled, result.Outcome!.Kind);
Assert.Equal(RendezvousConnectionOutcomeSource.Caller, result.Outcome.Source);
}
private static CreateJoinAttemptResponse CreateAttempt() => new()
{
AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000202")),
@@ -95,6 +199,15 @@ public sealed class RendezvousJoinClientTests
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero),
};
private static CreateJoinAttemptRequest CreateRequest(string idempotencyKey) => new()
{
IdempotencyKey = idempotencyKey,
GameId = new("space-game"),
EnvironmentId = new("production"),
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000230")),
ProtocolVersion = 7,
};
private static HostJoinAttempt CreateHostAttempt(string id) => new()
{
AttemptId = new(Guid.Parse(id)),
@@ -137,6 +250,22 @@ public sealed class RendezvousJoinClientTests
}
}
private sealed class CancellingHandler : HttpMessageHandler
{
internal TaskCompletionSource Started { get; } = new(
TaskCreationOptions.RunContinuationsAsynchronously);
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
_ = request;
Started.TrySetResult();
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
throw new InvalidOperationException("The silent request unexpectedly completed.");
}
}
private sealed record RecordedRequest(
HttpMethod Method,
Uri Uri,