perf(esg): bound outbound response bodies (ResponseHeadersRead + MaxResponseBodyBytes cap; oversize = permanent failure)

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 04:19:02 -04:00
parent eabd2820eb
commit 761729b5d0
3 changed files with 115 additions and 2 deletions
@@ -942,6 +942,59 @@ public class ExternalSystemClientTests
}
}
// ── ESG: bound outbound response buffering (MaxResponseBodyBytes) ──
[Fact]
public async Task OversizedResponseBody_FailsPermanently_WithoutBufferingWholeBody()
{
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
StubResolution(system, method);
// The endpoint returns a 2 MiB body — far above the 1 KiB cap below.
// Hand out a fresh HttpClient per CreateClient call: the client mutates
// HttpClient.Timeout before its first request, so a single shared instance
// cannot serve the two calls this test makes.
var hugeBody = new string('x', 2 * 1024 * 1024);
_httpClientFactory.CreateClient(Arg.Any<string>())
.Returns(_ => new HttpClient(new MockHttpMessageHandler(HttpStatusCode.OK, hugeBody)));
// A real S&F service so we can assert the oversized response is NOT buffered.
var dbName = $"EsgOversize_{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
using var keepAlive = new SqliteConnection(connStr);
keepAlive.Open();
var storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
await storage.InitializeAsync();
var sfOptions = new StoreAndForwardOptions
{
DefaultRetryInterval = TimeSpan.FromMinutes(10),
RetryTimerInterval = TimeSpan.FromMinutes(10),
};
var sf = new StoreAndForwardService(storage, sfOptions, NullLogger<StoreAndForwardService>.Instance);
var options = new ExternalSystemGatewayOptions { MaxResponseBodyBytes = 1024 };
var client = new ExternalSystemClient(
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance,
storeAndForward: sf,
options: Microsoft.Extensions.Options.Options.Create(options));
// Sync call: oversize is a permanent failure — retrying will not shrink it.
var result = await client.CallAsync("TestAPI", "postData");
Assert.False(result.Success);
Assert.Contains("exceeded", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
// Cached call of the same shape: a permanent failure must NOT be buffered
// into S&F (buffering it would defeat the response-size cap).
var cached = await client.CachedCallAsync("TestAPI", "postData");
Assert.False(cached.WasBuffered);
var depth = await storage.GetBufferDepthByCategoryAsync();
Assert.False(
depth.TryGetValue(ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.StoreAndForwardCategory.ExternalSystem, out var n) && n > 0,
"An oversized (permanent-failure) response must not be buffered for retry");
}
/// <summary>
/// Test helper: mock HTTP message handler.
/// </summary>