fix(grpc): keep the RpcException off the failover warning so no sink can render Status.Detail

Review follow-ups: the warning now carries only the two endpoint URIs and the
status code — attaching the exception object would let any real sink render
its ToString(), which for RpcException includes server-authored Status.Detail.
The leak pin test now also asserts no exception rides on the warning, via a
new WarningExceptions capture on RecordingLogger. Plus two cosmetic nits:
unified ThrowIfNull idiom, documented that FallbackEndpoints mutation after
Validate is unsupported.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
Joseph Doherty
2026-08-07 09:49:45 -04:00
parent 0d4978953f
commit 7a2980e2f1
4 changed files with 29 additions and 8 deletions
@@ -78,9 +78,11 @@ internal sealed partial class FailoverSecretsHubReader : ISecretsHubReader
nameof(endpointLabels));
}
ArgumentNullException.ThrowIfNull(logger);
_readers = readers;
_endpointLabels = endpointLabels;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_logger = logger;
}
/// <inheritdoc />
@@ -115,11 +117,13 @@ internal sealed partial class FailoverSecretsHubReader : ISecretsHubReader
if (attempt < _readers.Count - 1)
{
// The message carries the two endpoint URIs and the status code — nothing from
// the exception detail, which is server-authored text no credential belongs in
// but this node cannot vouch for.
// The exception object is deliberately NOT attached to this warning: sinks
// render an attached exception's ToString(), which for RpcException includes
// Status.Detail — server-authored text no credential belongs in but this node
// cannot vouch for. The two endpoint URIs plus the status code are what make
// the warning actionable, and the terminal failure still reaches the sweep's
// catch-all with the full exception when every endpoint has failed.
LogEndpointFailedOver(
ex,
_endpointLabels[index],
ex.StatusCode,
_endpointLabels[(start + attempt + 1) % _readers.Count]);
@@ -137,5 +141,5 @@ internal sealed partial class FailoverSecretsHubReader : ISecretsHubReader
Level = LogLevel.Warning,
Message = "Secrets hub endpoint {FailedEndpoint} did not answer ({StatusCode}); failing over to {NextEndpoint}.")]
private partial void LogEndpointFailedOver(
Exception exception, string failedEndpoint, StatusCode statusCode, string nextEndpoint);
string failedEndpoint, StatusCode statusCode, string nextEndpoint);
}
@@ -38,8 +38,10 @@ public sealed class SecretsGrpcHubClientOptions
/// </summary>
/// <remarks>
/// Getter-only so the configuration binder adds entries to this instance rather than needing a
/// setter. Each entry carries the same http-cleartext caveat as <see cref="Endpoint"/>: a plain
/// <c>http</c> fallback sends <see cref="BearerToken"/> in cleartext on every failed-over call.
/// setter. Mutating the list after <see cref="Validate"/> has run is unsupported — the DI
/// extension reads it exactly once, at registration, to shape the channel graph. Each entry
/// carries the same http-cleartext caveat as <see cref="Endpoint"/>: a plain <c>http</c>
/// fallback sends <see cref="BearerToken"/> in cleartext on every failed-over call.
/// </remarks>
public IList<string> FallbackEndpoints { get; } = [];