Repository: JasperFx/wolverine (2026-07-17T22:34:32Z)

Issue:
Kafka: degrade-only connection state via SetErrorHandler/SetStatisticsHandler (follow-up to #3237)
## Context

Split out of #3237, which decided the approach for SDK-opaque transports: derive a **degrade-only** `ConnectionState` from real SDK callbacks — only ever `Disconnected`/`Reconnecting`, never a synthesized `Connected`, resting state `Unknown`, liveness answered by loop-health + `LastQueueActivityAt` (#3236). Azure Service Bus and GCP Pub/Sub were implemented in the companion PR; Kafka needs its own design beat, hence this issue.

## What Kafka should report

Per the #3237 decision:

- `SetErrorHandler`: `Error.IsFatal` (e.g. fenced) or all-brokers-down → `Disconnected`
- `SetStatisticsHandler`: `cgrp.state` (e.g. `rebalancing`) and per-broker `state` → `Reconnecting`
- Any successfully consumed message clears back to `Unknown` (not `Connected`)

## The wrinkle that makes this its own issue

Wolverine builds consumers through `ConsumerBuilder<string, byte[]>` and then hands the builder to user code via `KafkaTransport.ConfigureConsumerBuilders` (`KafkaTransport.cs` — the builder is constructed and the user callback invoked in `CreateConsumer`). **Confluent's builder throws if `SetErrorHandler` / `SetStatisticsHandler` is called twice.** So if Wolverine naively claims either handler:

- any existing user who sets one via `ConfigureConsumerBuilders` starts throwing at startup — a breaking change, or
- if Wolverine registers *after* the user callback, Wolverine's registration is the one that throws.

Options to evaluate:

1. **Wrap-and-delegate**: Wolverine owns the handler registration, tracks the connection state, then forwards the callback to an optional user-supplied delegate exposed through new Wolverine-level configuration (e.g. on `KafkaListenerConfiguration` / `KafkaTransport`). Existing `ConfigureConsumerBuilders` users who set handlers would need a documented migration, or Wolverine detects it can't register and degrades to no-op with a log line.
2. **Statistics-only**: skip `SetErrorHandler` entirely (users commonly claim it), derive from `SetStatisticsHandler` only — but statistics emission is off by default (`statistics.interval.ms=0`), so Wolverine would need to opt it on conservatively, and it's still subject to the same double-registration throw.
3. **Opt-in**: only register handlers when the user asks for connection-state reporting on Kafka endpoints, so no existing configuration can break.

Also worth checking while in there: `statistics.interval.ms` needs a sane default (e.g. 5–15s) when the statistics path is used, and the `cgrp.state` JSON parse must not add per-message overhead (it's a periodic callback, so it shouldn't).

## Constraints (inherited from #3237)

- Never report `Connected` from a heuristic
- No per-message overhead
- Real SDK callbacks only — no timers



Discussion:

Patch:
diff --git a/src/Transports/Kafka/Wolverine.Kafka/Internals/KafkaConnectionStateTracker.cs b/src/Transports/Kafka/Wolverine.Kafka/Internals/KafkaConnectionStateTracker.cs
new file mode 100644
index 000000000..93b50cfff
--- /dev/null
+++ b/src/Transports/Kafka/Wolverine.Kafka/Internals/KafkaConnectionStateTracker.cs
@@ -0,0 +1,59 @@
+using Confluent.Kafka;
+using Wolverine.Transports;
+
+namespace Wolverine.Kafka.Internals;
+
+/// <summary>
+/// GH-3454 (split from GH-3237): librdkafka hides connection/group state behind <c>Consume()</c>, so this
+/// tracker derives a degrade-only <see cref="TransportConnectionState"/> from the consumer's error callback.
+/// It may only ever move state toward trouble (Reconnecting/Disconnected), never synthesize Connected — the
+/// resting state for a healthy consumer is Unknown, and a successfully consumed record clears back to it.
+/// Liveness is answered separately by <see cref="BackgroundReceiveLoop"/> health (GH-3236).
+/// </summary>
+public class KafkaConnectionStateTracker : IReportConnectionState
+{
+    private volatile TransportConnectionState _connectionState = TransportConnectionState.Unknown;
+
+    public TransportConnectionState ConnectionState => _connectionState;
+
+    /// <summary>
+    /// True when the error handler could not be registered because user configuration already claimed it
+    /// through ConfigureConsumerBuilders (Confluent's builder throws on double registration). The state
+    /// then rests at Unknown permanently rather than breaking existing configurations.
+    /// </summary>
+    public bool ErrorHandlerSuppressed { get; internal set; }
+
+    public void ApplyError(Error error)
+    {
+        if (error.IsFatal || error.Code == ErrorCode.Local_AllBrokersDown)
+        {
+            _connectionState = TransportConnectionState.Disconnected;
+            return;
+        }
+
+        switch (error.Code)
+        {
+            // Broker-level transport trouble that librdkafka retries under the covers. Never allowed to
+            // downgrade Disconnected — the aggregate all-brokers-down signal outranks a per-broker error,
+            // and only a successful consume clears it
+            case ErrorCode.Local_Transport:
+            case ErrorCode.Local_TimedOut:
+            case ErrorCode.Local_Resolve:
+                if (_connectionState != TransportConnectionState.Disconnected)
+                {
+                    _connectionState = TransportConnectionState.Reconnecting;
+                }
+
+                break;
+        }
+    }
+
+    public void MarkSuccessfulConsume()
+    {
+        if (_connectionState != TransportConnectionState.Unknown)
+        {
+            // Records are flowing again, so any previously derived trouble state is stale
+            _connectionState = TransportConnectionState.Unknown;
+        }
+    }
+}
diff --git a/src/Transports/Kafka/Wolverine.Kafka/Internals/KafkaListener.cs b/src/Transports/Kafka/Wolverine.Kafka/Internals/KafkaListener.cs
index 7e7a8fce6..18e1787ee 100644
--- a/src/Transports/Kafka/Wolverine.Kafka/Internals/KafkaListener.cs
+++ b/src/Transports/Kafka/Wolverine.Kafka/Internals/KafkaListener.cs
@@ -8,8 +8,12 @@
 
 namespace Wolverine.Kafka.Internals;
 
-public class KafkaListener : IListener, IDisposable, ISupportDeadLetterQueue, IReportReceiveLoopHealth
+public class KafkaListener : IListener, IDisposable, ISupportDeadLetterQueue, IReportReceiveLoopHealth,
+    IReportConnectionState
 {
+    // GH-3454: degrade-only connection state derived from the consumer's error callback; a successful
+    // consume clears back to Unknown. Never Connected — see KafkaConnectionStateTracker.
+    private readonly KafkaConnectionStateTracker _connectionState;
     private readonly KafkaTopic _endpoint;
     private readonly IConsumer<string, byte[]> _consumer;
     private CancellationTokenSource _cancellation = new();
@@ -31,7 +35,8 @@ public class KafkaListener : IListener, IDisposable, ISupportDeadLetterQueue, IR
 
     public KafkaListener(KafkaTopic topic, ConsumerConfig config,
         IConsumer<string, byte[]> consumer, IReceiver receiver,
-        ILogger<KafkaListener> logger, TimeSpan drainTimeout, KafkaTransport? tenantTransport = null)
+        ILogger<KafkaListener> logger, TimeSpan drainTimeout, KafkaTransport? tenantTransport = null,
+        KafkaConnectionStateTracker? connectionState = null)
     {
         _endpoint = topic;
         _logger = logger;
@@ -40,6 +45,14 @@ public KafkaListener(KafkaTopic topic, ConsumerConfig config,
         Address = topic.Uri;
         _consumer = consumer;
 
+        _connectionState = connectionState ?? new KafkaConnectionStateTracker();
+        if (_connectionState.ErrorHandlerSuppressed)
+        {
+            _logger.LogInformation(
+                "Kafka connection-state reporting is disabled for {Uri} because user configuration already registers an error handler through ConfigureConsumerBuilders; ConnectionState will remain Unknown",
+                Address);
+        }
+
         _messageTypeName = topic.MessageType?.ToMessageTypeName();
 
         Config = config;
@@ -60,6 +73,8 @@ private async Task<bool> consumeOnceAsync(CancellationToken token)
     {
         var result = _consumer.Consume(token);
 
+        _connectionState.MarkSuccessfulConsume();
+
         try
         {
             var message = result.Message;
@@ -121,6 +136,8 @@ private async Task<bool> consumeOnceAsync(CancellationToken token)
     public ReceiveLoopStatus ReceiveLoopStatus => _loop.ReceiveLoopStatus;
     public DateTimeOffset? LastReceiveLoopActivityAt => _loop.LastReceiveLoopActivityAt;
 
+    public TransportConnectionState ConnectionState => _connectionState.ConnectionState;
+
     public ValueTask CompleteAsync(Envelope envelope)
     {
         if (envelope.TopicName != null && envelope.PartitionId.HasValue)
diff --git a/src/Transports/Kafka/Wolverine.Kafka/Internals/KafkaTopicGroupListener.cs b/src/Transports/Kafka/Wolverine.Kafka/Internals/KafkaTopicGroupListener.cs
index ea54b5d6f..963dcc9c5 100644
--- a/src/Transports/Kafka/Wolverine.Kafka/Internals/KafkaTopicGroupListener.cs
+++ b/src/Transports/Kafka/Wolverine.Kafka/Internals/KafkaTopicGroupListener.cs
@@ -8,8 +8,12 @@
 
 namespace Wolverine.Kafka.Internals;
 
-public class KafkaTopicGroupListener : IListener, IDisposable, ISupportDeadLetterQueue, IReportReceiveLoopHealth
+public class KafkaTopicGroupListener : IListener, IDisposable, ISupportDeadLetterQueue, IReportReceiveLoopHealth,
+    IReportConnectionState
 {
+    // GH-3454: degrade-only connection state derived from the consumer's error callback; a successful
+    // consume clears back to Unknown. Never Connected — see KafkaConnectionStateTracker.
+    private readonly KafkaConnectionStateTracker _connectionState;
     private readonly KafkaTopicGroup _endpoint;
     private readonly IConsumer<string, byte[]> _consumer;
     private CancellationTokenSource _cancellation = new();
@@ -30,7 +34,8 @@ public class KafkaTopicGroupListener : IListener, IDisposable, ISupportDeadLette
 
     public KafkaTopicGroupListener(KafkaTopicGroup endpoint, ConsumerConfig config,
         IConsumer<string, byte[]> consumer, IReceiver receiver,
-        ILogger<KafkaTopicGroupListener> logger, TimeSpan drainTimeout, KafkaTransport? tenantTransport = null)
+        ILogger<KafkaTopicGroupListener> logger, TimeSpan drainTimeout, KafkaTransport? tenantTransport = null,
+        KafkaConnectionStateTracker? connectionState = null)
     {
         _endpoint = endpoint;
         _logger = logger;
@@ -39,6 +44,14 @@ public KafkaTopicGroupListener(KafkaTopicGroup endpoint, ConsumerConfig config,
         Address = endpoint.Uri;
         _consumer = consumer;
 
+        _connectionState = connectionState ?? new KafkaConnectionStateTracker();
+        if (_connectionState.ErrorHandlerSuppressed)
+        {
+            _logger.LogInformation(
+                "Kafka connection-state reporting is disabled for {Uri} because user configuration already registers an error handler through ConfigureConsumerBuilders; ConnectionState will remain Unknown",
+                Address);
+        }
+
         Config = config;
         _receiver = receiver;
         _committer = new KafkaOffsetCommitter(consumer, config, endpoint.CommitMode, endpoint.CommitBatchCount,
@@ -57,6 +70,8 @@ private async Task<bool> consumeOnceAsync(CancellationToken token)
     {
         var result = _consumer.Consume(token);
 
+        _connectionState.MarkSuccessfulConsume();
+
         try
         {
             var message = result.Message;
@@ -105,6 +120,8 @@ private async Task<bool> consumeOnceAsync(CancellationToken token)
     public ReceiveLoopStatus ReceiveLoopStatus => _loop.ReceiveLoopStatus;
     public DateTimeOffset? LastReceiveLoopActivityAt => _loop.LastReceiveLoopActivityAt;
 
+    public TransportConnectionState ConnectionState => _connectionState.ConnectionState;
+
     public ValueTask CompleteAsync(Envelope envelope)
     {
         if (envelope.TopicName != null && envelope.PartitionId.HasValue)
diff --git a/src/Transports/Kafka/Wolverine.Kafka/Internals/KafkaTransport.cs b/src/Transports/Kafka/Wolverine.Kafka/Internals/KafkaTransport.cs
index 9671a4273..9ec1300a3 100644
--- a/src/Transports/Kafka/Wolverine.Kafka/Internals/KafkaTransport.cs
+++ b/src/Transports/Kafka/Wolverine.Kafka/Internals/KafkaTransport.cs
@@ -321,10 +321,27 @@ internal IProducer<string, byte[]> CreateProducer(ProducerConfig? config)
         return producerBuilder.Build();
     }
 
-    internal IConsumer<string, byte[]> CreateConsumer(ConsumerConfig? config)
+    internal IConsumer<string, byte[]> CreateConsumer(ConsumerConfig? config,
+        KafkaConnectionStateTracker? tracker = null)
     {
         var consumerBuilder = new ConsumerBuilder<string, byte[]>(config ?? ConsumerConfig);
         ConfigureConsumerBuilders(consumerBuilder);
+
+        if (tracker != null)
+        {
+            // GH-3454: registered after the user's ConfigureConsumerBuilders callback because Confluent's
+            // builder throws on double registration. If the user already claimed the error handler, their
+            // registration stands and this consumer's connection state simply rests at Unknown.
+            try
+            {
+                consumerBuilder.SetErrorHandler((_, error) => tracker.ApplyError(error));
+            }
+            catch (InvalidOperationException)
+            {
+                tracker.ErrorHandlerSuppressed = true;
+            }
+        }
+
         return consumerBuilder.Build();
     }
 
diff --git a/src/Transports/Kafka/Wolverine.Kafka/KafkaTopic.cs b/src/Transports/Kafka/Wolverine.Kafka/KafkaTopic.cs
index 694aefefd..d9f678e0a 100644
--- a/src/Transports/Kafka/Wolverine.Kafka/KafkaTopic.cs
+++ b/src/Transports/Kafka/Wolverine.Kafka/KafkaTopic.cs
@@ -234,9 +234,12 @@ public override ValueTask<IListener> BuildListenerAsync(IWolverineRuntime runtim
         // on Kafka's background committer flushing manually stored offsets.
         KafkaOffsetCommitter.ApplyTo(config, CommitMode);
 
+        // GH-3454: the tracker is wired into the consumer's error callback (when not claimed by user
+        // configuration) and handed to the listener for IReportConnectionState
+        var tracker = new KafkaConnectionStateTracker();
         var listener = new KafkaListener(this, config,
-            Parent.CreateConsumer(config), receiver, runtime.LoggerFactory.CreateLogger<KafkaListener>(),
-            runtime.DurabilitySettings.DrainTimeout);
+            Parent.CreateConsumer(config, tracker), receiver, runtime.LoggerFactory.CreateLogger<KafkaListener>(),
+            runtime.DurabilitySettings.DrainTimeout, connectionState: tracker);
 
         // Broker-per-tenant (GH-3303): the shared listener consumes the default cluster. Each tenant runs its
         // own listener on its own cluster, stamping the tenant id onto inbound envelopes via TenantIdRule.
@@ -251,10 +254,11 @@ public override ValueTask<IListener> BuildListenerAsync(IWolverineRuntime runtim
             {
                 var tenantConfig = cloneConsumerConfigForTenant(config, tenant);
                 var tenantReceiver = new ReceiverWithRules(receiver, [new TenantIdRule(tenant.TenantId)]);
+                var tenantTracker = new KafkaConnectionStateTracker();
                 var tenantListener = new KafkaListener(this, tenantConfig,
-                    tenant.Transport.CreateConsumer(tenantConfig), tenantReceiver,
+                    tenant.Transport.CreateConsumer(tenantConfig, tenantTracker), tenantReceiver,
                     runtime.LoggerFactory.CreateLogger<KafkaListener>(), runtime.DurabilitySettings.DrainTimeout,
-                    tenant.Transport);
+                    tenant.Transport, tenantTracker);
                 compound.Inner.Add(tenantListener);
             }
 
diff --git a/src/Transports/Kafka/Wolverine.Kafka/KafkaTopicGroup.cs b/src/Transports/Kafka/Wolverine.Kafka/KafkaTopicGroup.cs
index f3039903b..3007f7bc8 100644
--- a/src/Transports/Kafka/Wolverine.Kafka/KafkaTopicGroup.cs
+++ b/src/Transports/Kafka/Wolverine.Kafka/KafkaTopicGroup.cs
@@ -48,9 +48,13 @@ public override ValueTask<IListener> BuildListenerAsync(IWolverineRuntime runtim
         // Wire the Kafka client for the configured commit strategy (GH-3150).
         KafkaOffsetCommitter.ApplyTo(config, CommitMode);
 
+        // GH-3454: the tracker is wired into the consumer's error callback (when not claimed by user
+        // configuration) and handed to the listener for IReportConnectionState
+        var tracker = new KafkaConnectionStateTracker();
         var listener = new KafkaTopicGroupListener(this, config,
-            Parent.CreateConsumer(config), receiver, runtime.LoggerFactory.CreateLogger<KafkaTopicGroupListener>(),
-            runtime.DurabilitySettings.DrainTimeout);
+            Parent.CreateConsumer(config, tracker), receiver,
+            runtime.LoggerFactory.CreateLogger<KafkaTopicGroupListener>(),
+            runtime.DurabilitySettings.DrainTimeout, connectionState: tracker);
 
         // Broker-per-tenant (GH-3303): mirror the single-topic listener treatment — a shared listener on the
         // default cluster plus one per-tenant listener on each tenant cluster, stamping the tenant id inbound.
@@ -63,10 +67,11 @@ public override ValueTask<IListener> BuildListenerAsync(IWolverineRuntime runtim
             {
                 var tenantConfig = cloneConsumerConfigForTenant(config, tenant);
                 var tenantReceiver = new ReceiverWithRules(receiver, [new TenantIdRule(tenant.TenantId)]);
+                var tenantTracker = new KafkaConnectionStateTracker();
                 var tenantListener = new KafkaTopicGroupListener(this, tenantConfig,
-                    tenant.Transport.CreateConsumer(tenantConfig), tenantReceiver,
+                    tenant.Transport.CreateConsumer(tenantConfig, tenantTracker), tenantReceiver,
                     runtime.LoggerFactory.CreateLogger<KafkaTopicGroupListener>(),
-                    runtime.DurabilitySettings.DrainTimeout, tenant.Transport);
+                    runtime.DurabilitySettings.DrainTimeout, tenant.Transport, tenantTracker);
                 compound.Inner.Add(tenantListener);
             }
 


Test patch:
diff --git a/src/Transports/Kafka/Wolverine.Kafka.Tests/connection_state_3454.cs b/src/Transports/Kafka/Wolverine.Kafka.Tests/connection_state_3454.cs
new file mode 100644
index 000000000..9da7888f7
--- /dev/null
+++ b/src/Transports/Kafka/Wolverine.Kafka.Tests/connection_state_3454.cs
@@ -0,0 +1,153 @@
+using Confluent.Kafka;
+using JasperFx.Core;
+using Microsoft.Extensions.Hosting;
+using JasperFx.Resources;
+using Shouldly;
+using Wolverine.ComplianceTests;
+using Wolverine.Configuration;
+using Wolverine.Kafka.Internals;
+using Wolverine.Tracking;
+using Wolverine.Transports;
+
+namespace Wolverine.Kafka.Tests;
+
+// GH-3454 (split from GH-3237): librdkafka exposes no queryable connection state, so the Kafka listeners
+// derive a degrade-only state from the consumer's error callback. The resting state for a healthy listener
+// is Unknown — the heuristic may move state toward trouble, but must never synthesize Connected. When user
+// configuration already claims SetErrorHandler through ConfigureConsumerBuilders, Wolverine backs off
+// instead of throwing on Confluent's double-registration guard.
+public class connection_state_3454
+{
+    [Fact]
+    public void fatal_error_means_disconnected()
+    {
+        var tracker = new KafkaConnectionStateTracker();
+        tracker.ApplyError(new Error(ErrorCode.Unknown, "boom", true));
+        tracker.ConnectionState.ShouldBe(TransportConnectionState.Disconnected);
+    }
+
+    [Fact]
+    public void all_brokers_down_means_disconnected()
+    {
+        var tracker = new KafkaConnectionStateTracker();
+        tracker.ApplyError(new Error(ErrorCode.Local_AllBrokersDown));
+        tracker.ConnectionState.ShouldBe(TransportConnectionState.Disconnected);
+    }
+
+    [Theory]
+    [InlineData(ErrorCode.Local_Transport)]
+    [InlineData(ErrorCode.Local_TimedOut)]
+    [InlineData(ErrorCode.Local_Resolve)]
+    public void broker_transport_trouble_means_reconnecting(ErrorCode code)
+    {
+        var tracker = new KafkaConnectionStateTracker();
+        tracker.ApplyError(new Error(code));
+        tracker.ConnectionState.ShouldBe(TransportConnectionState.Reconnecting);
+    }
+
+    [Fact]
+    public void a_per_broker_error_never_downgrades_all_brokers_down()
+    {
+        var tracker = new KafkaConnectionStateTracker();
+        tracker.ApplyError(new Error(ErrorCode.Local_AllBrokersDown));
+        tracker.ApplyError(new Error(ErrorCode.Local_Transport));
+        tracker.ConnectionState.ShouldBe(TransportConnectionState.Disconnected);
+    }
+
+    [Fact]
+    public void message_level_errors_say_nothing_about_the_connection()
+    {
+        var tracker = new KafkaConnectionStateTracker();
+        tracker.ApplyError(new Error(ErrorCode.OffsetOutOfRange));
+        tracker.ConnectionState.ShouldBe(TransportConnectionState.Unknown);
+    }
+
+    [Fact]
+    public void a_successful_consume_clears_derived_trouble_back_to_unknown()
+    {
+        var tracker = new KafkaConnectionStateTracker();
+        tracker.ApplyError(new Error(ErrorCode.Local_AllBrokersDown));
+        tracker.MarkSuccessfulConsume();
+        tracker.ConnectionState.ShouldBe(TransportConnectionState.Unknown);
+    }
+
+    [Fact]
+    public async Task healthy_listener_rests_at_unknown_and_never_reports_connected()
+    {
+        using var host = await Host.CreateDefaultBuilder()
+            .UseWolverine(opts =>
+            {
+                opts.UseKafka(KafkaContainerFixture.ConnectionString).AutoProvision();
+                opts.Policies.DisableConventionalLocalRouting();
+                opts.Discovery.IncludeAssembly(GetType().Assembly);
+                opts.Services.AddResourceSetupOnStartup();
+
+                opts.PublishMessage<ConnStateMessage>().ToKafkaTopic("connstate-3454");
+                // BeginAtEarliest so a record produced before the group finishes joining is still consumed
+                opts.ListenToKafkaTopic("connstate-3454").BeginAtEarliest();
+            }).StartAsync();
+
+        // Prove records actually flow...
+        await host.TrackActivity().IncludeExternalTransports().Timeout(60.Seconds())
+            .WaitForMessageToBeReceivedAt<ConnStateMessage>(host)
+            .SendMessageAndWaitAsync(new ConnStateMessage());
+
+        // ...and that a working listener still reports Unknown, never a synthesized Connected
+        var snapshot = host.GetRuntime().Endpoints.CollectEndpointHealth()
+            .Single(s => s.Direction == EndpointDirection.Listening && s.Uri.Scheme == "kafka");
+
+        snapshot.ConnectionState.ShouldBe(TransportConnectionState.Unknown);
+    }
+
+    [Fact]
+    public async Task unreachable_broker_degrades_to_disconnected()
+    {
+        // Nothing listens on this port, so librdkafka raises transport errors and then all-brokers-down.
+        // No AutoProvision — admin calls against a dead broker would stall startup
+        using var host = await Host.CreateDefaultBuilder()
+            .UseWolverine(opts =>
+            {
+                opts.UseKafka("localhost:19092");
+                opts.ListenToKafkaTopic("connstate-dead");
+            }).StartAsync();
+
+        var state = await ConnectionStateTestHelpers.WaitForListenerConnectionStateAsync(
+            host, "kafka", TransportConnectionState.Disconnected, 30000);
+
+        state.ShouldBe(TransportConnectionState.Disconnected);
+    }
+
+    [Fact]
+    public async Task user_claimed_error_handler_backs_off_instead_of_throwing()
+    {
+        var userHandlerHits = 0;
+
+        // The user's SetErrorHandler registration through ConfigureConsumerBuilders must keep working
+        // exactly as before GH-3454: no double-registration throw at startup, user callback still fires,
+        // and Wolverine's connection state simply rests at Unknown
+        using var host = await Host.CreateDefaultBuilder()
+            .UseWolverine(opts =>
+            {
+                opts.UseKafka("localhost:19092")
+                    .ConfigureConsumerBuilders(b =>
+                        b.SetErrorHandler((_, _) => Interlocked.Increment(ref userHandlerHits)));
+                opts.ListenToKafkaTopic("connstate-user-handler");
+            }).StartAsync();
+
+        // Poll for the state we must NOT reach; the helper returns the last observed state on timeout
+        var state = await ConnectionStateTestHelpers.WaitForListenerConnectionStateAsync(
+            host, "kafka", TransportConnectionState.Disconnected, 10000);
+
+        state.ShouldBe(TransportConnectionState.Unknown);
+        userHandlerHits.ShouldBeGreaterThan(0);
+    }
+}
+
+public record ConnStateMessage;
+
+public static class ConnStateMessageHandler
+{
+    public static void Handle(ConnStateMessage message)
+    {
+    }
+}