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:
@@ -322,7 +322,12 @@ public class ExternalSystemClient : IExternalSystemClient
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.SendAsync(request, linkedCts.Token);
|
||||
// ResponseHeadersRead returns as soon as the headers are in, so the
|
||||
// body is not eagerly buffered — the bounded read below streams it and
|
||||
// aborts once MaxResponseBodyBytes is exceeded, keeping a hostile or
|
||||
// misbehaving endpoint from inflating the active node's memory.
|
||||
response = await client.SendAsync(
|
||||
request, HttpCompletionOption.ResponseHeadersRead, linkedCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -347,7 +352,8 @@ public class ExternalSystemClient : IExternalSystemClient
|
||||
string body;
|
||||
try
|
||||
{
|
||||
body = await response.Content.ReadAsStringAsync(linkedCts.Token);
|
||||
body = await ReadBodyBoundedAsync(
|
||||
response.Content, system.Name, _options.MaxResponseBodyBytes, linkedCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -431,6 +437,51 @@ public class ExternalSystemClient : IExternalSystemClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an HTTP response body into a string while enforcing an upper size
|
||||
/// bound. When the <c>Content-Length</c> header is present it fails fast;
|
||||
/// otherwise the body is streamed in 16 KiB chunks and the read is aborted the
|
||||
/// moment the accumulated length exceeds <paramref name="maxBytes"/> (cap+1
|
||||
/// pattern), so the whole oversized body is never buffered. Oversize is a
|
||||
/// permanent failure — retrying will not shrink the response, and buffering it
|
||||
/// into store-and-forward would defeat the cap. Cancellation
|
||||
/// (<see cref="OperationCanceledException"/>) is allowed to propagate so the
|
||||
/// caller's ordered timeout/cancellation catch filters classify it.
|
||||
/// </summary>
|
||||
private static async Task<string> ReadBodyBoundedAsync(
|
||||
HttpContent content, string systemName, long maxBytes, CancellationToken token)
|
||||
{
|
||||
// Fast path: a declared Content-Length above the cap is rejected before a
|
||||
// single body byte is read.
|
||||
var declaredLength = content.Headers.ContentLength;
|
||||
if (declaredLength.HasValue && declaredLength.Value > maxBytes)
|
||||
{
|
||||
throw new PermanentExternalSystemException(
|
||||
$"Response from {systemName} exceeded the {maxBytes}-byte limit " +
|
||||
$"(declared Content-Length {declaredLength.Value}).");
|
||||
}
|
||||
|
||||
await using var stream = await content.ReadAsStreamAsync(token);
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[16 * 1024];
|
||||
int read;
|
||||
while ((read = await stream.ReadAsync(chunk.AsMemory(0, chunk.Length), token)) > 0)
|
||||
{
|
||||
buffer.Write(chunk, 0, read);
|
||||
|
||||
// cap+1: as soon as we hold more than maxBytes we know it is oversized,
|
||||
// without reading the remainder of the stream.
|
||||
if (buffer.Length > maxBytes)
|
||||
{
|
||||
throw new PermanentExternalSystemException(
|
||||
$"Response from {systemName} exceeded the {maxBytes}-byte limit.");
|
||||
}
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length);
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxChars)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxChars)
|
||||
|
||||
@@ -10,4 +10,13 @@ public class ExternalSystemGatewayOptions
|
||||
|
||||
/// <summary>Maximum number of concurrent HTTP connections per external system.</summary>
|
||||
public int MaxConcurrentConnectionsPerSystem { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound (bytes) on an outbound HTTP response body the gateway will
|
||||
/// buffer into memory. A response whose <c>Content-Length</c> (when present)
|
||||
/// or streamed length exceeds this cap fails permanently — retrying will not
|
||||
/// shrink it, and buffering it into store-and-forward would defeat the cap.
|
||||
/// Default 10 MiB.
|
||||
/// </summary>
|
||||
public long MaxResponseBodyBytes { get; set; } = 10 * 1024 * 1024;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user