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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user