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
@@ -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;
}