Resolve Client.Dotnet-004..008 code-review findings

Client.Dotnet-004: documented DefaultCallTimeout as both the per-attempt
deadline and the shared retry budget, and removed DeadlineExceeded from the
transient-retry set (a client-imposed deadline cannot be helped by retrying).

Client.Dotnet-005: RegisterAsync/AddItemAsync/AddItem2Async silently returned
0 when a successful reply lacked the typed payload. They now throw a
descriptive MxGatewayException.

Client.Dotnet-006: added XML docs to the previously undocumented public
members MaxGrpcMessageBytes, GatewayProtocolVersion, WorkerProtocolVersion.

Client.Dotnet-007: corrected the AcknowledgeAlarmAsync XML comment — the RPC
requires the admin scope, not a non-existent invoke:alarm-ack sub-scope.

Client.Dotnet-008: the CLI redactor missed env-var-sourced keys because the
caller passed only the --api-key option. Redaction now uses the same
resolver, stripping env-var keys too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-18 22:42:27 -04:00
parent 1764eff1cf
commit 89043cb2b6
10 changed files with 208 additions and 29 deletions
@@ -184,9 +184,10 @@ public sealed class MxGatewayClient : IAsyncDisposable
/// <summary>
/// Acknowledges an active MXAccess alarm condition through the gateway. The
/// gateway authenticates the request against the API key's <c>invoke:alarm-ack</c>
/// scope and forwards the acknowledge to the worker's MXAccess session;
/// the resulting <see cref="MxStatusProxy"/> is returned in the reply.
/// gateway authorizes <see cref="AcknowledgeAlarmRequest"/> against the API
/// key's <c>admin</c> scope (there is no finer-grained alarm-ack sub-scope)
/// and forwards the acknowledge to the worker's MXAccess session; the
/// resulting <see cref="MxStatusProxy"/> is returned in the reply.
/// </summary>
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
@@ -7,9 +7,19 @@ namespace MxGateway.Client;
/// </summary>
public static class MxGatewayClientContractInfo
{
/// <summary>
/// Gets the gateway gRPC protocol version compiled into this client package.
/// A client and gateway are wire-compatible only when this value matches the
/// gateway's advertised gateway protocol version.
/// </summary>
public const uint GatewayProtocolVersion =
GatewayContractInfo.GatewayProtocolVersion;
/// <summary>
/// Gets the worker frame protocol version compiled into this client package.
/// Exposed for diagnostics so callers can report the worker protocol the
/// shared contracts were generated against.
/// </summary>
public const uint WorkerProtocolVersion =
GatewayContractInfo.WorkerProtocolVersion;
}
@@ -38,7 +38,12 @@ public sealed class MxGatewayClientOptions
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Gets the default timeout for unary gRPC calls.
/// Gets the timeout budget for a unary gRPC operation. This is both the gRPC
/// deadline stamped on each individual attempt and the overall budget for the
/// whole safe-unary operation: for retryable calls the initial attempt, every
/// retry, and the backoff delays between them all share this single budget.
/// It is therefore an upper bound on the total wall-clock time a safe-unary
/// call can take, not a fresh per-retry allowance.
/// </summary>
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
@@ -47,6 +52,11 @@ public sealed class MxGatewayClientOptions
/// </summary>
public TimeSpan? StreamTimeout { get; init; }
/// <summary>
/// Gets the maximum size, in bytes, of a single gRPC message the client will
/// send or receive. Applied to both the send and receive limits of the
/// underlying channel. Defaults to 16 MiB.
/// </summary>
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
/// <summary>
@@ -61,8 +61,13 @@ internal static class MxGatewayClientRetryPolicy
private static bool IsTransientStatus(StatusCode statusCode)
{
// DeadlineExceeded is intentionally NOT treated as transient. The deadline
// on every unary call is client-imposed (CreateCallOptions stamps the
// DefaultCallTimeout budget), and that same budget is shared across the
// initial attempt plus all retries plus backoff. A DeadlineExceeded means
// the shared budget is exhausted, so an immediate retry would only fail
// again — burning the remaining budget on a call that cannot succeed.
return statusCode is StatusCode.Unavailable
or StatusCode.DeadlineExceeded
or StatusCode.ResourceExhausted;
}
}
@@ -101,7 +101,8 @@ public sealed class MxGatewaySession : IAsyncDisposable
MxCommandReply reply = await RegisterRawAsync(clientName, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.Register?.ServerHandle ?? reply.ReturnValue.Int32Value;
return reply.Register?.ServerHandle
?? throw CreateMissingPayloadException(reply, "register");
}
/// <summary>
@@ -143,7 +144,8 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.AddItem?.ItemHandle ?? reply.ReturnValue.Int32Value;
return reply.AddItem?.ItemHandle
?? throw CreateMissingPayloadException(reply, "add_item");
}
/// <summary>
@@ -194,7 +196,8 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.AddItem2?.ItemHandle ?? reply.ReturnValue.Int32Value;
return reply.AddItem2?.ItemHandle
?? throw CreateMissingPayloadException(reply, "add_item2");
}
/// <summary>
@@ -723,4 +726,21 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken);
}
/// <summary>
/// Builds the exception thrown when a command reply passed protocol and
/// MXAccess success checks but is missing the typed handle-bearing payload
/// the command contract requires. Surfacing this as a clear error avoids
/// silently handing a zero handle to the caller (it would otherwise fall
/// through to <see cref="MxCommandReply.ReturnValue"/>, which is 0 when the
/// reply carries no return value).
/// </summary>
private static MxGatewayException CreateMissingPayloadException(
MxCommandReply reply,
string expectedPayload)
{
return new MxGatewayException(
$"Gateway reply for command kind={reply.Kind} reported success but is missing "
+ $"the required '{expectedPayload}' payload; cannot resolve a handle. "
+ $"session={reply.SessionId}; correlation={reply.CorrelationId}");
}
}