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
+7
View File
@@ -123,6 +123,13 @@ read-path degradation an earlier failed `/current` left (the read path is answer
checks the answer's `instanceId`: a changed one (an Agent restart seen by a browse-only driver with no pump) drops the
cached `/probe` model and raises `OnRediscoveryNeeded` once per distinct id — it used to be detected only by the pump.
**An Agent refusal is a reply (health contract).** An `MTConnectError` document answering `/current` (e.g.
`NO_DEVICE` for a wrong `DeviceName` scope) still fails the batch (`BadCommunicationError` per ref), but it is the
Agent answering, not a comms fault: the read path is not degraded by it (that would let the comms-loss watchdog
reconnect-loop the driver over a configuration problem), `LastSuccessfulRead` advances, a prior read degradation
clears, and the Agent's text is logged once per distinct refusal. An answer that is not an MTConnect document at all
(a proxy's HTML page, truncated XML) stays a comms fault.
## Minimum deployment
```jsonc
@@ -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 =
@@ -159,4 +159,73 @@ public sealed class MTConnectReadPathTests
await driver.ShutdownAsync(CancellationToken.None);
}
}
private const string NoDeviceError =
"""
<?xml version="1.0" encoding="UTF-8"?>
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
<Header creationTime="2026-10-22T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
<Errors><Error errorCode="NO_DEVICE">Could not find the device 'VMC-9'</Error></Errors>
</MTConnectError>
""";
private static Exception ParseFailure(string xml)
{
try
{
MTConnectStreamsParser.Parse(xml);
}
catch (Exception ex)
{
return ex;
}
throw new InvalidOperationException("expected the parse to fail");
}
/// <summary>
/// Health contract: an MTConnectError answer to /current is the Agent REFUSING (it answered) — the batch is Bad,
/// but health is restored / kept Healthy with the stamp advanced, and the refusal is logged once, not per read.
/// </summary>
[Fact]
public async Task An_agent_refusal_of_current_is_a_reply_not_a_comms_fault()
{
var logger = new RecordingDriverLogger();
var client = CannedAgentClient.FromFixtures();
var driver = new MTConnectDriver(Opts(), "mt-refusal", _ => client, logger);
await driver.InitializeAsync("{}", Ct);
try
{
// A comms fault first, so the refusal has a Degraded to restore.
client.CurrentFailure = new HttpRequestException("connection refused");
await driver.ReadAsync(["dev1_pos"], Ct);
driver.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: a comms fault does degrade");
var stamp = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
var refusal = ParseFailure(NoDeviceError);
MTConnectXml.IsAgentRefusal(refusal).ShouldBeTrue();
client.CurrentFailure = refusal;
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow <= stamp && DateTime.UtcNow < deadline) await Task.Yield();
var first = await driver.ReadAsync(["dev1_pos"], Ct);
await driver.ReadAsync(["dev1_pos"], Ct);
first.Single().StatusCode.ShouldBe(0x80050000u, "the batch is still Bad");
var h = driver.GetHealth();
h.State.ShouldBe(DriverState.Healthy);
h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(stamp);
logger.WarningsSnapshot().Count(w => w.Contains("NO_DEVICE")).ShouldBe(1, "a persistent refusal warns once");
}
finally
{
client.CurrentFailure = null;
await driver.ShutdownAsync(CancellationToken.None);
}
}
/// <summary>An answer that is not an MTConnect document at all is NOT marked a refusal (it stays a comms fault).</summary>
[Fact]
public void A_garbage_answer_is_not_an_agent_refusal() =>
MTConnectXml.IsAgentRefusal(ParseFailure("<html><body>proxy error</body></html>")).ShouldBeFalse();
}