fix(external-system-gateway): resolve ExternalSystemGateway-015..017 — treat MaxRetries=0 as unset, scope HTTP connection cap to gateway clients, no bare trailing '?'

This commit is contained in:
Joseph Doherty
2026-05-17 03:18:24 -04:00
parent 4fa6f0e774
commit da8c9f171b
7 changed files with 211 additions and 35 deletions

View File

@@ -113,8 +113,13 @@ public class DatabaseGatewayTests
}
[Fact]
public async Task CachedWrite_ZeroMaxRetriesIsHonouredNotTreatedAsUnset()
public async Task CachedWrite_ZeroMaxRetriesIsTreatedAsUnsetNotRetryForever()
{
// ExternalSystemGateway-015: a stored MaxRetries of 0 is interpreted by the
// Store-and-Forward retry sweep as "no limit" (retry forever). The entity's
// non-nullable int default is also 0, so the gateway must treat the
// connection's MaxRetries == 0 as "unset" and pass null — the bounded S&F
// default must apply, never 0.
var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test")
{
Id = 1,
@@ -144,7 +149,9 @@ public class DatabaseGatewayTests
await gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (1)");
var (maxRetries, _) = ReadBufferedRetrySettings(connStr);
Assert.Equal(0, maxRetries); // honoured — not the S&F default of 99
// Must be the bounded S&F default, never 0 — a stored 0 would mean retry-forever.
Assert.Equal(99, maxRetries);
Assert.NotEqual(0, maxRetries);
}
private static (int MaxRetries, long RetryIntervalMs) ReadBufferedRetrySettings(string connStr)

View File

@@ -396,9 +396,14 @@ public class ExternalSystemClientTests
}
[Fact]
public async Task CachedCall_TransientFailure_ZeroMaxRetriesIsHonouredNotTreatedAsUnset()
public async Task CachedCall_TransientFailure_ZeroMaxRetriesIsTreatedAsUnsetNotRetryForever()
{
// MaxRetries == 0 must mean "never retry", not "fall back to the S&F default".
// ExternalSystemGateway-015: the Store-and-Forward engine interprets a stored
// MaxRetries of 0 as "no limit" (retry forever) — see StoreAndForwardMessage.cs
// and the retry-sweep guard `MaxRetries > 0 && ...`. The entity's non-nullable
// int default is also 0, so passing 0 verbatim would buffer every cached call
// as an unbounded retry loop. The ESG must therefore treat the entity's
// MaxRetries == 0 as "unset" and pass null, so the bounded S&F default applies.
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none")
{
Id = 1,
@@ -432,7 +437,9 @@ public class ExternalSystemClientTests
await client.CachedCallAsync("TestAPI", "postData");
var (maxRetries, _) = ReadBufferedRetrySettings(connStr);
Assert.Equal(0, maxRetries); // honoured — not the default of 99
// Must be the bounded S&F default, never 0 — a stored 0 would mean retry-forever.
Assert.Equal(99, maxRetries);
Assert.NotEqual(0, maxRetries);
}
// ── ExternalSystemGateway-005: HttpRequestMessage / HttpResponseMessage disposal ──
@@ -615,6 +622,33 @@ public class ExternalSystemClientTests
Assert.Contains("page=2", uri);
}
[Fact]
public async Task Call_GetWithAllNullParameters_DoesNotAppendTrailingQuestionMark()
{
// ExternalSystemGateway-017: a GET method invoked with a non-empty parameter
// dictionary whose values are all null has an effectively empty query string.
// The URL must be identical to the no-parameters case — no bare trailing '?'.
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
var method = new ExternalSystemMethod("search", "GET", "/search") { Id = 1, ExternalSystemDefinitionId = 1 };
StubResolution(system, method);
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
var client = new ExternalSystemClient(
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
await client.CallAsync("TestAPI", "search", new Dictionary<string, object?>
{
["q"] = null,
["page"] = null,
});
var uri = handler.LastUri!.AbsoluteUri;
Assert.Equal("https://api.example.com/search", uri);
Assert.DoesNotContain("?", uri);
}
[Fact]
public async Task Call_PostWithParameters_SendsJsonBody()
{

View File

@@ -40,6 +40,52 @@ public class ServiceWiringTests
Assert.Equal(4, sockets.MaxConnectionsPerServer);
}
[Fact]
public void MaxConcurrentConnectionsPerSystem_IsNotAppliedToNonGatewayHttpClients()
{
// ExternalSystemGateway-016: the gateway's connection cap must be scoped to
// its own per-system clients ("ExternalSystem_{name}"). It must NOT leak onto
// unrelated HttpClient consumers in the same host process (e.g. the
// Notification Service's OAuth2 token client) — that would silently throttle
// and override the primary-handler configuration of another component.
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaLink:ExternalSystemGateway:MaxConcurrentConnectionsPerSystem"] = "4",
})
.Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IConfiguration>(config);
services.AddSingleton(Substitute.For<IExternalSystemRepository>());
services.AddExternalSystemGateway();
// A client owned by a different component, registered the way the
// Notification Service registers its OAuth2 token client — a plain
// AddHttpClient with no custom primary handler. Its primary handler must
// remain the framework default (uncapped), not the gateway's SocketsHttpHandler.
services.AddHttpClient("NotificationService_OAuth2");
using var provider = services.BuildServiceProvider();
var handlerFactory = provider.GetRequiredService<IHttpMessageHandlerFactory>();
// The gateway's own client must still get the gateway cap.
var gatewayPrimary = FindPrimaryHandler(handlerFactory.CreateHandler("ExternalSystem_AnySystem"));
Assert.Equal(4, Assert.IsType<SocketsHttpHandler>(gatewayPrimary).MaxConnectionsPerServer);
// The unrelated component's client must NOT inherit the gateway's connection
// cap. With ConfigureHttpClientDefaults the primary handler is a
// SocketsHttpHandler capped at the gateway's value (the leak); with a scoped
// registration it is the framework default whose MaxConnectionsPerServer is
// int.MaxValue.
var otherPrimary = FindPrimaryHandler(handlerFactory.CreateHandler("NotificationService_OAuth2"));
if (otherPrimary is SocketsHttpHandler otherSockets)
{
Assert.NotEqual(4, otherSockets.MaxConnectionsPerServer);
}
}
private static HttpMessageHandler FindPrimaryHandler(HttpMessageHandler handler)
{
var current = handler;