fix(inbound): in-memory request buffering; relaying standby skips audit capture (review 2026-10-21 05/M4)

This commit is contained in:
Joseph Doherty
2026-10-22 08:30:18 -04:00
parent 6c9dfaf700
commit 26baa5a170
3 changed files with 59 additions and 4 deletions
@@ -3,6 +3,7 @@ using System.Diagnostics;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Audit;
@@ -221,11 +222,21 @@ public sealed class AuditWriteMiddleware
&& opts.PerTargetOverrides.TryGetValue(methodNameForOverride, out var perTarget)
&& perTarget.SkipBodyCapture;
// A standby that will relay this request to the active peer neither executes nor
// audits it (the serving node owns the single row, see `relayed` below). Skip the
// request/response capture entirely so the body is read once — by the forwarder —
// instead of buffered here, buffered again by the forwarder, and a response copy
// captured only to be discarded (review 2026-10-21 05/M4). If every peer refuses
// and the standby does emit its own failure row, that row simply carries no bodies.
var willRelay = WillRelayToActivePeer(ctx);
var requestBody = (string?)null;
var requestTruncated = false;
if (!skipBody && RequestHasBody(ctx.Request))
if (!skipBody && !willRelay && RequestHasBody(ctx.Request))
{
ctx.Request.EnableBuffering();
// Bodies are capped at MaxRequestBodyBytes (1 MiB default): keep them in memory
// rather than spilling everything above the 30 KB default threshold to a temp file.
ctx.Request.EnableBuffering((int)Math.Min(maxBytes, int.MaxValue));
try
{
(requestBody, requestTruncated) = await ReadBufferedRequestBodyAsync(ctx.Request, cap).ConfigureAwait(false);
@@ -243,7 +254,8 @@ public sealed class AuditWriteMiddleware
// original Response.Body is restored in the finally block.
var originalResponseBody = ctx.Response.Body;
using var captureStream = new CapturedResponseStream(originalResponseBody, cap);
ctx.Response.Body = captureStream;
if (!willRelay)
ctx.Response.Body = captureStream;
Exception? thrown = null;
try
@@ -294,6 +306,19 @@ public sealed class AuditWriteMiddleware
}
}
/// <summary>
/// True when this node is a standby with a configured relay and the request has not
/// already been relayed once — i.e. <see cref="InboundApiEndpointFilter"/> will forward it.
/// Mirrors the filter's own relay decision.
/// </summary>
private static bool WillRelayToActivePeer(HttpContext ctx)
{
var gate = ctx.RequestServices?.GetService<IActiveNodeGate>();
if (gate is not { IsActiveNode: false }) return false;
if (ctx.Request.Headers.ContainsKey(HttpStandbyRequestForwarder.ForwardedRequestHeader)) return false;
return ctx.RequestServices!.GetService<IStandbyRequestForwarder>() is { IsConfigured: true };
}
/// <summary>
/// Builds and writes the <see cref="AuditChannel.ApiInbound"/> row for the
/// request. Wrapped in try/catch so a thrown writer or any other emission
@@ -64,7 +64,7 @@ internal static class RelayProvenance
catch (Exception ex) when (ex is FormatException or JsonException or ArgumentException) { return; }
// Authenticate small metadata before reading; bound even non-Kestrel request streams.
context.Request.EnableBuffering();
context.Request.EnableBuffering((int)Math.Min(options.MaxRequestBodyBytes, int.MaxValue));
var body = await ReadBoundedAsync(context.Request.Body, options.MaxRequestBodyBytes, context.RequestAborted);
context.Request.Body.Position = 0;
if (!string.Equals(proof.Binding, Binding(context.Request, body), StringComparison.Ordinal)) return;