fix(mtconnect): an Agent refusal of /current is a reply, not a comms fault

Health contract: an MTConnectError document (NO_DEVICE, INVALID_REQUEST, ...) is the
Agent answering. The parsers now mark such an InvalidDataException
(MTConnectXml.IsAgentRefusal - the type is sealed, so a Data marker, every other
handler unchanged); ReadAsync reports the batch Bad but does not degrade the read
path, advances LastSuccessfulRead, clears a prior read degradation and warns once
per distinct refusal. An unparseable answer stays a comms fault.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 16:03:38 -04:00
parent d67634cc4f
commit ac01c88c0e
7 changed files with 137 additions and 4 deletions
@@ -369,6 +369,9 @@ public sealed partial class MTConnectDriver
/// <summary>Whether the <c>/current</c> read path is currently failing. See <see cref="PublishHealthy"/>.</summary>
private volatile bool _readDegraded;
/// <summary>The last <c>/current</c> refusal text logged, so a persistent refusal warns once, not per read.</summary>
private string? _lastReadRefusal;
/// <summary>
/// Whether the current-generation <c>/sample</c> pump has delivered since it started (a chunk, a heartbeat or
/// a re-baseline) — the precondition, with <see cref="_streamDegraded"/> clear, for <see cref="ReadAsync"/>
@@ -770,6 +773,23 @@ public sealed partial class MTConnectDriver
// handled below as the Agent failure it is.
throw;
}
catch (InvalidDataException ex) when (MTConnectXml.IsAgentRefusal(ex))
{
// The health contract: an MTConnectError document is the Agent REFUSING the request (a bad device
// scope, an unsupported request) — it answered. The batch is Bad, but this is not a comms fault: the
// read path is not degraded by it (that would reconnect-loop a driver over a config problem), the stamp
// advances, and the Agent's text is logged once per distinct refusal.
_readDegraded = false;
PublishHealthy(DateTime.UtcNow);
if (!string.Equals(Interlocked.Exchange(ref _lastReadRefusal, ex.Message), ex.Message, StringComparison.Ordinal))
{
_logger.LogWarning(
"MTConnect driver {DriverInstanceId}: {AgentUri} refused /current — {AgentError}. Every read is reported Bad until the Agent accepts the request; the driver stays connected.",
_driverInstanceId, AgentDisplay(options), ex.Message);
}
return BadBatch(fullReferences.Count, BadCommunicationError);
}
catch (Exception ex)
{
DegradeAfterReadFailure(ex);
@@ -118,7 +118,7 @@ internal static class MTConnectProbeParser
if (!MTConnectXml.IsNamed(root, "MTConnectDevices"))
{
throw new InvalidDataException(MTConnectXml.DescribeUnexpectedRoot(root, "MTConnectDevices", Subject));
throw MTConnectXml.UnexpectedRoot(root, "MTConnectDevices", Subject);
}
var devicesContainer = MTConnectXml.ChildrenNamed(root, "Devices").FirstOrDefault()
@@ -159,3 +159,4 @@ public sealed class MTConnectStreamNotSupportedException : MTConnectStreamExcept
{
}
}
@@ -295,8 +295,7 @@ internal static class MTConnectStreamsParser
// exactly this shape (Agents serve one under HTTP 200), and its <Error> elements — the
// Agent's own account of what it objected to — are the most useful thing in the whole
// response. Materialising the root is how the shared extraction can reach them.
throw new InvalidDataException(
MTConnectXml.DescribeUnexpectedRoot((XElement)XNode.ReadFrom(reader), "MTConnectStreams", Subject));
throw MTConnectXml.UnexpectedRoot((XElement)XNode.ReadFrom(reader), "MTConnectStreams", Subject);
}
var observations = new List<MTConnectObservation>();
@@ -190,7 +190,44 @@ internal static class MTConnectXml
/// <param name="root">The document's actual root element.</param>
/// <param name="expectedRootName">The root element name the caller required.</param>
/// <param name="subject">How to describe the document in the error message.</param>
/// <returns>The error message describing the unexpected root.</returns>
/// <returns>
/// The exception to throw. For an <c>MTConnectError</c> root (the Agent REFUSED the request) it is marked
/// with <see cref="AgentRefusalDataKey"/> — see <see cref="IsAgentRefusal"/>.
/// </returns>
public static InvalidDataException UnexpectedRoot(XElement root, string expectedRootName, string subject)
{
var ex = new InvalidDataException(DescribeUnexpectedRoot(root, expectedRootName, subject));
// Marked (InvalidDataException is sealed, so not a subtype) so the read path can tell a refusal — the
// Agent answered — from an answer it could not parse at all. Every other handler sees the same exception.
if (IsNamed(root, "MTConnectError"))
{
ex.Data[AgentRefusalDataKey] = true;
}
return ex;
}
/// <summary><see cref="Exception.Data"/> key marking an <c>MTConnectError</c> answer (see <see cref="IsAgentRefusal"/>).</summary>
internal const string AgentRefusalDataKey = "MTConnect.AgentRefusal";
/// <summary>
/// Whether <paramref name="ex"/> is the Agent answering with an <c>MTConnectError</c> document (e.g.
/// <c>NO_DEVICE</c>, <c>INVALID_REQUEST</c>) — a refusal, as opposed to an answer that could not be parsed.
/// </summary>
/// <param name="ex">The exception a parse raised.</param>
/// <returns><see langword="true"/> for a refusal.</returns>
public static bool IsAgentRefusal(Exception ex) =>
ex is InvalidDataException && ex.Data[AgentRefusalDataKey] is true;
/// <summary>
/// The text of <see cref="UnexpectedRoot"/>: the unexpected root, plus the Agent's own error text for an
/// <c>MTConnectError</c> document.
/// </summary>
/// <param name="root">The document's root element.</param>
/// <param name="expectedRootName">The root the caller expected.</param>
/// <param name="subject">How to describe the document.</param>
/// <returns>The message.</returns>
public static string DescribeUnexpectedRoot(XElement root, string expectedRootName, string subject)
{
var message =