Repository navigation
Expose per-attempt connection outcomes with the selected TLS client certificate #3252
Description
Summary
Please add supported Tunnel hooks for the outcome of every physical connection attempt.
The hooks should:
- observe initial connections and reconnects;
- report successful establishment after the Redis handshake completes;
- report every failed attempt, including reconnect failures suppressed by the public
ConnectionFailedevent; - include an identifier for the client certificate selected for that physical attempt;
- include the exception and failure stage when unsuccessful.
Motivation
We maintain a managed client-certificate integration for StackExchange.Redis. It selects a client certificate for each physical TLS connection and reports whether that exact certificate succeeded or failed, allowing fallback to a previously known-good certificate.
In short:
I want to provide a client certificate and know whether the physical connection using that certificate succeeded.
Tunnel is already a configuration-time extension point and is available before ConnectionMultiplexer.Connect returns, so it can observe initial connections as well as later reconnects. Adding physical-attempt outcome hooks there would avoid introducing another independent configuration callback.
Current limitations
Reconnect failures can be suppressed
PhysicalBridge.OnConnectionFailed raises the public ConnectionFailed event only while reportNextFailure is true.
After the first failure, the flag remains false until that bridge establishes a connection. Subsequent reconnect failures are therefore not reported through ConnectionFailed.
This creates the following sequence:
- Redis is connected successfully.
- The established connection closes with
SocketClosed. ConnectionFailedis raised, consuming the bridge's notification.- A newly rotated client certificate is selected for reconnect.
- The Redis server rejects that certificate.
- The reconnect fails, but no new
ConnectionFailedevent is raised because connectivity was never restored. - The integration cannot report the rejected certificate or activate its known-good fallback.
- Subsequent reconnects continue using the rejected certificate.
A custom failure classifier cannot fix a notification that is never delivered.
Certificate selection and outcomes are separate
LocalCertificateSelectionCallback reports which certificate was selected, while ConnectionFailed and ConnectionRestored report outcomes independently.
The outcome does not identify the selected certificate. Maintaining a global FIFO queue is unsafe because interactive and subscription connections can attempt reconnection concurrently and complete out of order.
For example:
- Certificate A is selected for one physical connection.
- Certificate B is selected for another.
- The attempts complete in the opposite order.
- Certificate B's rejection can be incorrectly attributed to A.
This can produce incorrect fallback decisions, incorrect telemetry, and retained certificate references.
Proposed Tunnel hooks
Names and argument organization are illustrative:
public abstract class Tunnel
{
public virtual ValueTask OnConnectionEstablishedAsync(
ConnectionAttemptEventArgs args,
CancellationToken cancellationToken) => default;
public virtual ValueTask OnConnectionAttemptFailedAsync(
ConnectionAttemptFailedEventArgs args,
CancellationToken cancellationToken) => default;
}Suggested arguments:
public class ConnectionAttemptEventArgs : EventArgs
{
public EndPoint EndPoint { get; }
public ConnectionType ConnectionType { get; }
public string? PhysicalName { get; }
public string? ClientCertificateThumbprint { get; }
}
public sealed class ConnectionAttemptFailedEventArgs
: ConnectionAttemptEventArgs
{
public ConnectionAttemptStage Stage { get; }
public ConnectionFailureType FailureType { get; }
public Exception Exception { get; }
public bool? ServerCertificateAccepted { get; }
public SslPolicyErrors? ServerCertificatePolicyErrors { get; }
}Suggested stages:
public enum ConnectionAttemptStage
{
SocketConnect,
TlsAuthentication,
RedisAuthentication,
RedisHandshake,
}An alternative is one exactly-once completion hook:
public virtual ValueTask OnConnectionAttemptCompletedAsync(
ConnectionAttemptCompletedEventArgs args,
CancellationToken cancellationToken) => default;where the arguments include success/failure, stage, failure type, and exception.
Either shape works provided that every physical attempt produces exactly one terminal outcome.
ClientCertificateThumbprint can be either a sha1 or sha256 thumbprint, with the end user determining which based on length.
Alternatively, a second property ClientCertificateThumbprintHashAlgorithm can be added which states the algorithm used (using the .net HashAlgorithmName).
The stage and server-validation result help consumers exclude failures unrelated to the client certificate. Consumers may still need platform-specific classification unless StackExchange.Redis can expose a structured positive signal such as ClientCertificateRejected or a peer TLS alert.
Example usage
internal sealed class ManagedCertificateTunnel : Tunnel
{
public override ValueTask OnConnectionEstablishedAsync(
ConnectionAttemptEventArgs args,
CancellationToken cancellationToken)
{
if (args.ClientCertificateThumbprint is not null)
{
// Report that the used certificate was healthy.
// This can be used for telemetry or to mark a last-known-good certificate.
}
return default;
}
public override ValueTask OnConnectionAttemptFailedAsync(
ConnectionAttemptFailedEventArgs args,
CancellationToken cancellationToken)
{
if (args.ClientCertificateThumbprint is not null
&& IsClientCertificateRejection(args))
{
// Report that the used certificate was unhealthy.
// This can be used for telemetry or to trigger fallback to a LKG certificate.
}
return default;
}
}Existing alternative
Tunnel.BeforeAuthenticateAsync makes a workaround possible today:
- The integration creates and authenticates its own
SslStream. - It disables StackExchange.Redis's normal TLS layer.
- It returns a custom stream that tracks the first Redis read and all pre-establishment I/O
failures.
This provides deterministic certificate correlation, including delayed TLS 1.3 alerts, but it requires the integration to take ownership of substantial transport behavior:
- TLS configuration and authentication;
- stream and socket lifetime;
- framework-specific synchronous and asynchronous stream members;
- cloning
SslClientAuthenticationOptions; - preserving existing custom tunnels;
- introducing a replacement for the write-only
ConfigurationOptions.CertificateValidation
event; - tracking StackExchange.Redis I/O behavior across versions.
Physical-attempt hooks would allow StackExchange.Redis to continue owning TLS and transport details while providing the small amount of lifecycle information required by certificate integrations.
Logging is not a suitable alternative application-control-flow contract. It does not provide the selected certificate, structured stage information, or a stable guarantee that every physical attempt will continue to be logged.
Compatibility
The proposed methods are additive virtual members with default no-op implementations.
The existing ConnectionFailed and ConnectionRestored events can retain their current suppression and notification behavior.
Activity
mgravell commentedon Sep 30, 2026
It seems to me that if you're after very tight fine-grained control/knowledge of that mechanism, this might instead be a scenario that better fits the (currently experimental)
ConnectTransportAsyncAPI, which transfers everything to the tunnel, including TLS handshake responsibility. This would then be achievable today; thoughts?tlupes commentedon Oct 1, 2026
This does achieve our goals, however doing so forces us to rewrite a large chunk of the TLS handling code that Redis currently owns. This causes what could be a relatively tiny change (implementing a couple hooks) into hundreds of lines of code largely copied out of the Redis codebase.
While it is functional today, my largest concern is any change or added functionality on the Redis side will need to be replicated on my side. Taking a quick look at the commit logs, we already have drift due to #3250, so this is not a theoretical issue but will be a constant source of maintenance on my end.
mgravell commentedon Oct 1, 2026
Since the client certificate is specified via a callback, I'd need to check, but: it is entirely possible that we simply don't see this. I'll have a think on what is possible.
tlupes commentedon Oct 1, 2026
The client certificate callback is a good chunk of the story, however the missing piece is reporting. Initially I started with the callback and used
ConnectionFailedto be notified when the certificate failed, however it is missing two things:I spoke with @NickCraver on this and he recommended the
ConnectTransportAsyncroute, which does allow me to do what I need to do, but requires a lot of additional code.mgravell commentedon Oct 2, 2026
I wonder if we ship a shrink wrapped tunnel adapter that you can subclass, which keeps the impact localized. In particular, it sounds like you need to know the actual certificate used: to me, this sounds like the implementation needs to intercept the callback to log that, since normally we never actually see the client cert.
It also raised the question of what to hand out: I'm very reluctant to give out the actual cert (especially since it will presumably have the private key); maybe a CN, Subject and ThumbPrint? Is that sufficient?
mgravell commentedon Oct 2, 2026
OK, an attempt is over in #3257 - thoughts and feedback please, @tlupes, since I imagine you're the primary consumer of this API.
tlupes commentedon Oct 2, 2026
Thank you very much for the quick turnaround! I am taking a look at the PR.
mgravell commentedon Oct 2, 2026
(I have some changes inbound soon based on automated review)
Report the outcome of every physical connection attempt, identifying …
mgravell commentedon Oct 12, 2026
Fixed in 3.4.0