diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs
index 9edb24b6..b9c0c7e3 100644
--- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs
@@ -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
}
}
+ ///
+ /// True when this node is a standby with a configured relay and the request has not
+ /// already been relayed once — i.e. will forward it.
+ /// Mirrors the filter's own relay decision.
+ ///
+ private static bool WillRelayToActivePeer(HttpContext ctx)
+ {
+ var gate = ctx.RequestServices?.GetService();
+ if (gate is not { IsActiveNode: false }) return false;
+ if (ctx.Request.Headers.ContainsKey(HttpStandbyRequestForwarder.ForwardedRequestHeader)) return false;
+ return ctx.RequestServices!.GetService() is { IsConfigured: true };
+ }
+
///
/// Builds and writes the row for the
/// request. Wrapped in try/catch so a thrown writer or any other emission
diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RelayProvenance.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RelayProvenance.cs
index 6f05783c..efd1aa1e 100644
--- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RelayProvenance.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RelayProvenance.cs
@@ -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;
diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs
index 085ffdc3..598f4a42 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs
@@ -62,6 +62,36 @@ public class AuditWriteMiddlewareTests
if (localRows > 0) Assert.NotNull(writer.Events[0].ExecutionId);
}
+ /// Verifies that a relaying standby skips request buffering and response capture.
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task M4_RelayingStandby_DoesNotBufferRequestOrCaptureResponse()
+ {
+ // Review 2026-10-21 05/M4: the standby neither executes nor audits a relayed call, so
+ // it must not buffer the body (the forwarder reads it once) or wrap the response.
+ var services = new ServiceCollection().AddLogging();
+ services.AddSingleton(new StandbyGate());
+ services.AddSingleton(new ResultForwarder(Results.Ok()));
+ using var provider = services.BuildServiceProvider();
+ var context = BuildContext("echo", "{}");
+ context.RequestServices = provider;
+ var originalRequest = context.Request.Body;
+ var originalResponse = new MemoryStream();
+ context.Response.Body = originalResponse;
+ Stream? seenRequest = null, seenResponse = null;
+ var middleware = CreateMiddleware(ctx =>
+ {
+ seenRequest = ctx.Request.Body;
+ seenResponse = ctx.Response.Body;
+ return Task.CompletedTask;
+ }, new RecordingAuditWriter());
+
+ await middleware.InvokeAsync(context);
+
+ Assert.Same(originalRequest, seenRequest);
+ Assert.Same(originalResponse, seenResponse);
+ }
+
private sealed class StandbyGate : IActiveNodeGate
{
///