fix(inbound): type SITE_UNREACHABLE on AskTimeoutException at the router await instead of message-substring sniffing (plan R2-06 T5)

This commit is contained in:
Joseph Doherty
2026-07-13 09:57:22 -04:00
parent b41b3b1b31
commit fb9dc7ad14
4 changed files with 114 additions and 35 deletions
+1 -1
View File
@@ -124,7 +124,7 @@ The `sbk_<keyId>_<secret>` design already supports zero-downtime rotation — a
| `BODY_TOO_LARGE` | 413 | Request body exceeded `MaxRequestBodyBytes`. | | `BODY_TOO_LARGE` | 413 | Request body exceeded `MaxRequestBodyBytes`. |
| `STANDBY_NODE` | 503 | Request hit a standby central node; the inbound API serves only the active node. | | `STANDBY_NODE` | 503 | Request hit a standby central node; the inbound API serves only the active node. |
| `TIMEOUT` | 500 | The method script exceeded its execution timeout. | | `TIMEOUT` | 500 | The method script exceeded its execution timeout. |
| `SITE_UNREACHABLE` | 500 | A routed `Route.To(...).Call(...)` could not reach the target site. | | `SITE_UNREACHABLE` | 500 | The target site did not respond to the routed request within the routing timeout (no contact / down / partitioned) — typed from the routed Ask expiring (`AskTimeoutException`), never inferred from an error-message substring. A `Success=false` response means the site answered and is classified `SCRIPT_ERROR`. |
| `SCRIPT_COMPILE_FAILED` | 500 | The method's script failed to compile (returned consistently on every node until a compiling version is saved). | | `SCRIPT_COMPILE_FAILED` | 500 | The method's script failed to compile (returned consistently on every node until a compiling version is saved). |
| `SCRIPT_ERROR` | 500 | Catch-all for any other script-execution failure (details are logged centrally, never leaked to the caller). | | `SCRIPT_ERROR` | 500 | Catch-all for any other script-execution failure (details are logged centrally, never leaked to the caller). |
@@ -1,3 +1,4 @@
using Akka.Actor;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types;
@@ -5,12 +6,16 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI; namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary> /// <summary>
/// C3: a routed call failed because the target site could not be reached (no contact / /// C3/N3: a routed call failed because the target site never answered — the routed
/// unreachable). Derives from <see cref="InvalidOperationException"/> so existing /// Ask <b>expired</b>. Unreachability is typed at the Ask boundary from
/// callers that catch the base type are unaffected, while /// <see cref="AskTimeoutException"/> (the communication layer's own signal: an
/// <see cref="InboundScriptExecutor"/> catches this more specific type to surface the /// unreachable/contactless site has its enveloped message dropped so the Ask times
/// spec's <c>SITE_UNREACHABLE</c> error code instead of a generic <c>SCRIPT_ERROR</c>. /// out caller-side) — it is <em>never</em> inferred from an error-message substring.
/// A routing failure whose message does <em>not</em> indicate unreachability stays a /// Derives from <see cref="InvalidOperationException"/> so existing callers that
/// catch the base type are unaffected, while <see cref="InboundScriptExecutor"/>
/// catches this more specific type to surface the spec's <c>SITE_UNREACHABLE</c>
/// error code instead of a generic <c>SCRIPT_ERROR</c>. A <c>Success=false</c>
/// <em>response</em> means the site answered (reachable by definition) and stays a
/// plain <see cref="InvalidOperationException"/>. /// plain <see cref="InvalidOperationException"/>.
/// </summary> /// </summary>
public sealed class SiteUnreachableException : InvalidOperationException public sealed class SiteUnreachableException : InvalidOperationException
@@ -18,6 +23,11 @@ public sealed class SiteUnreachableException : InvalidOperationException
/// <summary>Initializes a new <see cref="SiteUnreachableException"/> with the given message.</summary> /// <summary>Initializes a new <see cref="SiteUnreachableException"/> with the given message.</summary>
/// <param name="message">The routing-level failure message that indicated the site was unreachable.</param> /// <param name="message">The routing-level failure message that indicated the site was unreachable.</param>
public SiteUnreachableException(string message) : base(message) { } public SiteUnreachableException(string message) : base(message) { }
/// <summary>Initializes a new <see cref="SiteUnreachableException"/> wrapping the underlying Ask-timeout.</summary>
/// <param name="message">The routing-level failure message.</param>
/// <param name="innerException">The underlying <see cref="AskTimeoutException"/> from the routed Ask.</param>
public SiteUnreachableException(string message, Exception innerException) : base(message, innerException) { }
} }
/// <summary> /// <summary>
@@ -182,7 +192,7 @@ public class RouteTarget
correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters), correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters),
DateTimeOffset.UtcNow, _parentExecutionId); DateTimeOffset.UtcNow, _parentExecutionId);
var response = await _instanceRouter.RouteToCallAsync(siteId, request, token); var response = await RouteOrUnreachable(_instanceRouter.RouteToCallAsync(siteId, request, token));
if (!response.Success) if (!response.Success)
{ {
@@ -230,7 +240,7 @@ public class RouteTarget
correlationId, _instanceCode, attributeNames.ToList(), DateTimeOffset.UtcNow, correlationId, _instanceCode, attributeNames.ToList(), DateTimeOffset.UtcNow,
_parentExecutionId); _parentExecutionId);
var response = await _instanceRouter.RouteToGetAttributesAsync(siteId, request, token); var response = await RouteOrUnreachable(_instanceRouter.RouteToGetAttributesAsync(siteId, request, token));
if (!response.Success) if (!response.Success)
{ {
@@ -294,7 +304,7 @@ public class RouteTarget
AttributeValueCodec.Encode(targetValue), timeout, DateTimeOffset.UtcNow, AttributeValueCodec.Encode(targetValue), timeout, DateTimeOffset.UtcNow,
_parentExecutionId); _parentExecutionId);
var response = await _instanceRouter.RouteToWaitForAttributeAsync(siteId, request, token); var response = await RouteOrUnreachable(_instanceRouter.RouteToWaitForAttributeAsync(siteId, request, token));
if (!response.Success) if (!response.Success)
{ {
@@ -345,7 +355,7 @@ public class RouteTarget
correlationId, _instanceCode, attributeValues, DateTimeOffset.UtcNow, correlationId, _instanceCode, attributeValues, DateTimeOffset.UtcNow,
_parentExecutionId); _parentExecutionId);
var response = await _instanceRouter.RouteToSetAttributesAsync(siteId, request, token); var response = await RouteOrUnreachable(_instanceRouter.RouteToSetAttributesAsync(siteId, request, token));
if (!response.Success) if (!response.Success)
{ {
@@ -366,9 +376,9 @@ public class RouteTarget
var siteId = await _instanceLocator.GetSiteIdForInstanceAsync(_instanceCode, cancellationToken); var siteId = await _instanceLocator.GetSiteIdForInstanceAsync(_instanceCode, cancellationToken);
if (siteId == null) if (siteId == null)
{ {
// Route the site-resolution failure through the same classifier so a // A null site id is a plain not-found / no-assigned-site — never
// message indicating the site is unreachable surfaces as SITE_UNREACHABLE; // unreachability (nothing was routed), so it stays a generic
// a plain not-found / no-assigned-site stays a generic InvalidOperationException. // InvalidOperationException (SCRIPT_ERROR).
throw RoutingFailure( throw RoutingFailure(
$"Instance '{_instanceCode}' not found or has no assigned site"); $"Instance '{_instanceCode}' not found or has no assigned site");
} }
@@ -377,19 +387,37 @@ public class RouteTarget
} }
/// <summary> /// <summary>
/// C3: classifies a routing-level failure message. When the message indicates the /// N3: awaits a routed Ask and TYPES unreachability. AskTimeoutException means
/// site could not be reached (unreachable / no contact) the failure is a /// the site never answered (no ClusterClient contact, site down, partition) —
/// <see cref="SiteUnreachableException"/> — so <see cref="InboundScriptExecutor"/> /// the communication layer's own typed signal (CentralCommunicationActor drops
/// can surface the spec's <c>SITE_UNREACHABLE</c> code — otherwise a plain /// envelopes for contactless sites so the Ask expires caller-side) — and
/// <see cref="InvalidOperationException"/>. /// surfaces as SiteUnreachableException so the executor maps it to the spec's
/// SITE_UNREACHABLE code. A Success=false RESPONSE means the site answered and
/// is therefore reachable; it flows through RoutingFailure as a plain
/// InvalidOperationException (SCRIPT_ERROR).
/// </summary> /// </summary>
private static InvalidOperationException RoutingFailure(string message) => private static async Task<TResponse> RouteOrUnreachable<TResponse>(Task<TResponse> routedAsk)
IsUnreachable(message) {
? new SiteUnreachableException(message) try
: new InvalidOperationException(message); {
return await routedAsk;
}
catch (AskTimeoutException ex)
{
throw new SiteUnreachableException(
"Site did not respond to the routed request within the routing timeout — unreachable or not connected.",
ex);
}
}
private static bool IsUnreachable(string message) => /// <summary>
message.Contains("unreachable", StringComparison.OrdinalIgnoreCase) /// C3/N3: a routing-level failure REPORTED BY the site or the locator. The
|| message.Contains("no contact", StringComparison.OrdinalIgnoreCase) /// remote end answered (or the instance simply has no site), so this is never
|| message.Contains("no-contact", StringComparison.OrdinalIgnoreCase); /// unreachability — always a plain <see cref="InvalidOperationException"/>
/// (SCRIPT_ERROR at the endpoint). Unreachability is typed at the Ask boundary
/// by <c>RouteOrUnreachable</c> (AskTimeoutException → SiteUnreachableException),
/// replacing the old message-substring sniff that a harmless rewording in the
/// communication layer could silently defeat.
/// </summary>
private static InvalidOperationException RoutingFailure(string message) => new(message);
} }
@@ -98,19 +98,17 @@ public class InboundScriptExecutorTests
} }
[Fact] [Fact]
public async Task RoutedSiteUnreachable_Returns500WithSiteUnreachableCode() public async Task RoutedAskTimeout_Returns500WithSiteUnreachableCode()
{ {
// C3: a routed call whose site is unreachable must surface the machine-readable // N3: unreachability is the Ask EXPIRING (site never answered) — typed as
// SITE_UNREACHABLE code (spec Component-InboundAPI failure body) rather than // AskTimeoutException by the communication layer — not a substring in a
// collapsing into a generic SCRIPT_ERROR. // failure message. Pre-fix this fell into the generic catch as SCRIPT_ERROR.
var locator = Substitute.For<IInstanceLocator>(); var locator = Substitute.For<IInstanceLocator>();
locator.GetSiteIdForInstanceAsync("X", Arg.Any<CancellationToken>()).Returns("SiteA"); locator.GetSiteIdForInstanceAsync("X", Arg.Any<CancellationToken>()).Returns("SiteA");
var router = Substitute.For<IInstanceRouter>(); var router = Substitute.For<IInstanceRouter>();
router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>()) router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse( .Returns(Task.FromException<RouteToCallResponse>(
((RouteToCallRequest)ci[1]).CorrelationId, new Akka.Actor.AskTimeoutException("Timeout after 00:00:30")));
Success: false, ReturnValue: null, ErrorMessage: "Site unreachable",
DateTimeOffset.UtcNow));
var route = new RouteHelper(locator, router); var route = new RouteHelper(locator, router);
var method = new ApiMethod("routed", "return await Route.To(\"X\").Call(\"s\");") var method = new ApiMethod("routed", "return await Route.To(\"X\").Call(\"s\");")
@@ -123,6 +121,33 @@ public class InboundScriptExecutorTests
Assert.Equal("SITE_UNREACHABLE", result.ErrorCode); Assert.Equal("SITE_UNREACHABLE", result.ErrorCode);
} }
[Fact]
public async Task SiteRespondedFailure_MessageMentioningUnreachable_IsScriptErrorNotSiteUnreachable()
{
// N3 anti-sniff: a site that RESPONDED with Success=false is reachable by
// definition — even when its error text happens to contain "unreachable".
// Pre-fix the substring sniff misclassified this as SITE_UNREACHABLE.
var locator = Substitute.For<IInstanceLocator>();
locator.GetSiteIdForInstanceAsync("X", Arg.Any<CancellationToken>()).Returns("SiteA");
var router = Substitute.For<IInstanceRouter>();
router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(
((RouteToCallRequest)ci[1]).CorrelationId,
Success: false, ReturnValue: null,
ErrorMessage: "PLC device unreachable behind the site gateway",
DateTimeOffset.UtcNow));
var route = new RouteHelper(locator, router);
var method = new ApiMethod("routed2", "return await Route.To(\"X\").Call(\"s\");")
{ Id = 8, TimeoutSeconds = 10 };
var result = await _executor.ExecuteAsync(
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10));
Assert.False(result.Success);
Assert.Equal("SCRIPT_ERROR", result.ErrorCode);
}
[Fact] [Fact]
public async Task HandlerTimesOut_ReturnsTimeoutError() public async Task HandlerTimesOut_ReturnsTimeoutError()
{ {
@@ -595,4 +595,30 @@ public class RouteHelperTests
Assert.NotNull(captured); Assert.NotNull(captured);
Assert.Equal(inboundExecutionId, captured!.ParentExecutionId); Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
} }
// --- N3: typed unreachability at the Ask boundary ---
[Fact]
public async Task Call_AskTimeout_ThrowsSiteUnreachableException()
{
SiteResolves("inst", "SiteA");
_router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException<RouteToCallResponse>(
new Akka.Actor.AskTimeoutException("Timeout after 00:00:30")));
await Assert.ThrowsAsync<SiteUnreachableException>(
() => CreateHelper().To("inst").Call("s"));
}
[Fact]
public async Task GetAttributes_AskTimeout_ThrowsSiteUnreachableException()
{
SiteResolves("inst", "SiteA");
_router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException<RouteToGetAttributesResponse>(
new Akka.Actor.AskTimeoutException("Timeout after 00:00:30")));
await Assert.ThrowsAsync<SiteUnreachableException>(
() => CreateHelper().To("inst").GetAttributes(new[] { "a" }));
}
} }