fix(mqtt): a broker-refused CONNECT no longer reports a Healthy driver
MQTTnet 5 does not throw on an unsuccessful CONNACK — it returns the reason in MqttClientConnectResult.ResultCode and v5 removed v4's ThrowOnNonSuccessfulConnectResponse, so ConnectCoreAsync's unconditional SetState(Connected) sealed a wrong-password deployment green: DriverState.Healthy / HostState.Running on a session that never authenticated. Caught by the Task-13 Mosquitto fixture; 240 offline tests missed it. ConnectCoreAsync now inspects the CONNACK and raises MqttConnectRejectedException. Credentials/identity/protocol/Last-Will refusals are unrecoverable and set Faulted, which stops the reconnect supervisor; transient refusals (ServerUnavailable, ServerBusy, QuotaExceeded, UseAnotherServer, ConnectionRateExceeded, and anything unrecognised) stay Reconnecting under backoff. MqttDriverProbe now shares the rejection wording, so the AdminUI Test-connect button and the running driver can no longer disagree. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -89,6 +89,43 @@ public enum MqttConnectionState
|
||||
Disposed = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The broker completed the handshake and <b>refused</b> the CONNECT, returning a non-success
|
||||
/// CONNACK reason code.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This exists because <b>MQTTnet 5 does not throw on an unsuccessful CONNACK</b> — it returns
|
||||
/// the outcome in <see cref="MqttClientConnectResult.ResultCode"/> and leaves the client
|
||||
/// disconnected, and v5 removed v4's <c>ThrowOnNonSuccessfulConnectResponse</c> option, so
|
||||
/// inspecting the result is the only way to see it. A caller that ignores the result gets a
|
||||
/// connect that "succeeded" against a broker which rejected it: a driver configured with the
|
||||
/// wrong broker password would report <c>Healthy</c> and never receive a single value. That is
|
||||
/// exactly what the Task-13 live fixture caught.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="IsUnrecoverable"/> distinguishes a rejection no amount of retrying can fix
|
||||
/// (credentials, identity, protocol, an invalid Last-Will) from a genuinely transient refusal
|
||||
/// (broker unavailable/busy, quota, rate limit). See
|
||||
/// <see cref="MqttConnection.IsUnrecoverableConnackRejection"/> for the full mapping.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MqttConnectRejectedException : Exception
|
||||
{
|
||||
internal MqttConnectRejectedException(MqttClientConnectResultCode resultCode, bool isUnrecoverable, string message)
|
||||
: base(message)
|
||||
{
|
||||
ResultCode = resultCode;
|
||||
IsUnrecoverable = isUnrecoverable;
|
||||
}
|
||||
|
||||
/// <summary>Whether retrying against this broker with this configuration can never succeed.</summary>
|
||||
public bool IsUnrecoverable { get; }
|
||||
|
||||
/// <summary>The CONNACK reason code the broker returned.</summary>
|
||||
public MqttClientConnectResultCode ResultCode { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the driver's single live MQTTnet-5 client: assembles the broker connection options
|
||||
/// from <see cref="MqttDriverOptions"/> (endpoint, protocol version, session, credentials,
|
||||
@@ -450,6 +487,12 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport
|
||||
/// <exception cref="ObjectDisposedException">
|
||||
/// The connection was disposed, either before the attempt started or while it was in flight.
|
||||
/// </exception>
|
||||
/// <exception cref="MqttConnectRejectedException">
|
||||
/// The broker refused the CONNECT (non-success CONNACK — bad credentials, unsupported protocol
|
||||
/// version, server unavailable …). MQTTnet reports this as a <i>result</i>, not an exception,
|
||||
/// so this method inspects the CONNACK and raises it: a caller that treats "did not throw" as
|
||||
/// "connected" would otherwise report a healthy session the broker never granted.
|
||||
/// </exception>
|
||||
public async Task ConnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Disposed, this);
|
||||
@@ -584,7 +627,15 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport
|
||||
return false;
|
||||
}
|
||||
|
||||
await client.ConnectAsync(clientOptions, linked.Token).ConfigureAwait(false);
|
||||
var connack = await client.ConnectAsync(clientOptions, linked.Token).ConfigureAwait(false);
|
||||
|
||||
// MQTTnet 5 does NOT throw for a broker that refused the CONNECT — it hands the reason
|
||||
// back here and leaves the client disconnected. Skipping this check is how a wrong broker
|
||||
// password produced a Healthy driver that never received a value (Task-13 live gate).
|
||||
if (connack is not null && connack.ResultCode != MqttClientConnectResultCode.Success)
|
||||
{
|
||||
throw BuildRejection(connack, supervisorAttempt);
|
||||
}
|
||||
|
||||
if (AfterConnectHookForTests is { } hook)
|
||||
{
|
||||
@@ -618,6 +669,12 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport
|
||||
{
|
||||
throw;
|
||||
}
|
||||
// A refused CONNECT is already fully classified — it must not be re-wrapped as a timeout or a
|
||||
// cancellation just because a token happened to fire while the CONNACK was being read.
|
||||
catch (MqttConnectRejectedException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw Classify(ex, cancellationToken, deadline);
|
||||
@@ -628,6 +685,101 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns a refused CONNACK into the thrown contract, setting <see cref="MqttConnectionState.Faulted"/>
|
||||
/// first when the refusal is unrecoverable — which is what stops the supervisor
|
||||
/// (<see cref="ReconnectSupervisorAsync"/> returns on <c>Faulted</c>) from retrying every
|
||||
/// backoff period against a broker that will never accept these credentials.
|
||||
/// </summary>
|
||||
private MqttConnectRejectedException BuildRejection(MqttClientConnectResult connack, bool supervisorAttempt)
|
||||
{
|
||||
var unrecoverable = IsUnrecoverableConnackRejection(connack.ResultCode);
|
||||
var message = DescribeConnackRejection($"{_options.Host}:{_options.Port}", connack.ResultCode);
|
||||
|
||||
if (unrecoverable)
|
||||
{
|
||||
SetState(MqttConnectionState.Faulted);
|
||||
_logger?.LogError(
|
||||
"MQTT driver '{DriverId}': {Message} Retrying cannot help; fix the configuration and redeploy.",
|
||||
_driverId,
|
||||
message);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Retryable: leave the state alone — Disconnected before a first connect, Reconnecting
|
||||
// under the supervisor — so the backoff loop keeps trying at its bounded rate.
|
||||
_logger?.LogWarning(
|
||||
"MQTT driver '{DriverId}': {Message} Treating as transient{Suffix}.",
|
||||
_driverId,
|
||||
message,
|
||||
supervisorAttempt ? " and continuing to retry" : string.Empty);
|
||||
}
|
||||
|
||||
return new MqttConnectRejectedException(connack.ResultCode, unrecoverable, message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a refused CONNACK can never be fixed by retrying, and so must fault the connection
|
||||
/// rather than leave it looping.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Unrecoverable covers credentials/identity (<c>BadUserNameOrPassword</c>,
|
||||
/// <c>NotAuthorized</c>, <c>ClientIdentifierNotValid</c>, <c>Banned</c>,
|
||||
/// <c>BadAuthenticationMethod</c>), protocol mismatch (<c>UnsupportedProtocolVersion</c>,
|
||||
/// <c>MalformedPacket</c>, <c>ProtocolError</c>, <c>PacketTooLarge</c>) and a CONNECT whose
|
||||
/// Last-Will the broker will not accept (<c>TopicNameInvalid</c>,
|
||||
/// <c>PayloadFormatInvalid</c>, <c>RetainNotSupported</c>, <c>QoSNotSupported</c>). Every
|
||||
/// one is a property of the configuration we keep re-sending.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>ServerMoved</c> (0x9D) is unrecoverable and <c>UseAnotherServer</c> (0x9C) is not,
|
||||
/// because MQTT 5 defines the first as "permanently use another server" and the second as
|
||||
/// "temporarily use another server" — the spec, not a guess.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Everything else — including <c>UnspecifiedError</c>, <c>ImplementationSpecificError</c>
|
||||
/// and any code a future broker invents — is treated as transient. That default is
|
||||
/// deliberate: wrongly faulting a transient refusal takes a recoverable driver down until
|
||||
/// someone redeploys, whereas wrongly retrying a permanent one costs a bounded, visibly
|
||||
/// <c>Reconnecting</c>, backoff-paced attempt that names the reason code in the log.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static bool IsUnrecoverableConnackRejection(MqttClientConnectResultCode code) => code switch
|
||||
{
|
||||
MqttClientConnectResultCode.MalformedPacket
|
||||
or MqttClientConnectResultCode.ProtocolError
|
||||
or MqttClientConnectResultCode.UnsupportedProtocolVersion
|
||||
or MqttClientConnectResultCode.ClientIdentifierNotValid
|
||||
or MqttClientConnectResultCode.BadUserNameOrPassword
|
||||
or MqttClientConnectResultCode.NotAuthorized
|
||||
or MqttClientConnectResultCode.Banned
|
||||
or MqttClientConnectResultCode.BadAuthenticationMethod
|
||||
or MqttClientConnectResultCode.TopicNameInvalid
|
||||
or MqttClientConnectResultCode.PacketTooLarge
|
||||
or MqttClientConnectResultCode.PayloadFormatInvalid
|
||||
or MqttClientConnectResultCode.RetainNotSupported
|
||||
or MqttClientConnectResultCode.QoSNotSupported
|
||||
or MqttClientConnectResultCode.ServerMoved => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Renders a refused CONNACK as a targeted, credential-free operator message. Shared with
|
||||
/// <c>MqttDriverProbe</c> so the AdminUI "Test connect" button and the running driver describe
|
||||
/// the same rejection in the same words.
|
||||
/// </summary>
|
||||
internal static string DescribeConnackRejection(string target, MqttClientConnectResultCode code) => code switch
|
||||
{
|
||||
MqttClientConnectResultCode.NotAuthorized or MqttClientConnectResultCode.BadUserNameOrPassword
|
||||
=> $"Broker at {target} rejected the credentials ({code}).",
|
||||
MqttClientConnectResultCode.UnsupportedProtocolVersion
|
||||
=> $"Broker at {target} does not support the configured protocol version ({code}).",
|
||||
MqttClientConnectResultCode.Banned
|
||||
=> $"Broker at {target} has banned this client ({code}).",
|
||||
_ => $"Broker at {target} refused CONNECT: {code}.",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Starts the supervisor on the first <i>successful</i> caller connect. Always called while
|
||||
/// holding the lifecycle gate, so the <c>??=</c> needs no further synchronisation.
|
||||
|
||||
@@ -152,16 +152,14 @@ public sealed class MqttDriverProbe : IDriverProbe
|
||||
/// <c>Success</c> — never an exception, see the type remarks) to a targeted, credential-free
|
||||
/// message.
|
||||
/// </summary>
|
||||
private static string ClassifyRejectedConnack(string target, MqttClientConnectResultCode code) => code switch
|
||||
{
|
||||
MqttClientConnectResultCode.NotAuthorized or MqttClientConnectResultCode.BadUserNameOrPassword
|
||||
=> $"Broker at {target} rejected the credentials ({code}).",
|
||||
MqttClientConnectResultCode.UnsupportedProtocolVersion
|
||||
=> $"Broker at {target} does not support the configured protocol version ({code}).",
|
||||
MqttClientConnectResultCode.Banned
|
||||
=> $"Broker at {target} has banned this client ({code}).",
|
||||
_ => $"Broker at {target} refused CONNECT: {code}.",
|
||||
};
|
||||
/// <remarks>
|
||||
/// Delegates to <see cref="MqttConnection.DescribeConnackRejection"/> so the "Test connect"
|
||||
/// button and the running driver report an identical rejection in identical words — they now
|
||||
/// both classify CONNACK, and disagreeing about it was the exact confusion the Task-13 live
|
||||
/// gate surfaced.
|
||||
/// </remarks>
|
||||
private static string ClassifyRejectedConnack(string target, MqttClientConnectResultCode code)
|
||||
=> MqttConnection.DescribeConnackRejection(target, code);
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a thrown connect failure. Precedence mirrors
|
||||
|
||||
Reference in New Issue
Block a user