Skip to content

Commit 63667b3

Browse files
fix: preserve outgoing response streams during body capture
1 parent 50bc90e commit 63667b3

2 files changed

Lines changed: 156 additions & 10 deletions

File tree

DebugProbe.AspNetCore.Tests/Handlers/DebugProbeHttpClientHandlerBodyTests.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Net;
2+
using System.Net.Http.Headers;
23
using System.Text;
34
using DebugProbe.AspNetCore.Handlers;
45
using DebugProbe.AspNetCore.Models;
@@ -94,6 +95,45 @@ public async Task Body_over_limit_is_truncated_and_marker_is_appended()
9495
Assert.StartsWith(new string('C', 1024), outgoing.ResponseBody);
9596
}
9697

98+
[Fact]
99+
public async Task Capturing_streamed_response_body_does_not_consume_body_for_caller()
100+
{
101+
const int limitKb = 1;
102+
var bodyText = new string('D', 2048);
103+
var responseContent = new StreamContent(new NonSeekableMemoryStream(Encoding.UTF8.GetBytes(bodyText)));
104+
responseContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain")
105+
{
106+
CharSet = Encoding.UTF8.WebName
107+
};
108+
109+
var options = new DebugProbeOptions { MaxBodyCaptureSizeKb = limitKb };
110+
var (client, entry) = BuildClient(options, responseContent);
111+
112+
using var response = await client.GetAsync("https://example.test/");
113+
114+
Assert.Equal(bodyText, await response.Content.ReadAsStringAsync());
115+
116+
var outgoing = Assert.Single(entry.OutgoingRequests);
117+
Assert.EndsWith("[truncated]", outgoing.ResponseBody);
118+
}
119+
120+
[Fact]
121+
public async Task Captured_body_uses_content_type_charset()
122+
{
123+
const string bodyText = "Hello \u0100";
124+
var responseContent = new StringContent(bodyText, Encoding.Unicode, "text/plain");
125+
126+
var options = new DebugProbeOptions { MaxBodyCaptureSizeKb = 1 };
127+
var (client, entry) = BuildClient(options, responseContent);
128+
129+
using var response = await client.GetAsync("https://example.test/");
130+
131+
Assert.Equal(bodyText, await response.Content.ReadAsStringAsync());
132+
133+
var outgoing = Assert.Single(entry.OutgoingRequests);
134+
Assert.Contains(bodyText, outgoing.ResponseBody);
135+
}
136+
97137
[Fact]
98138
public async Task Null_content_returns_empty_string()
99139
{
@@ -132,4 +172,9 @@ protected override Task<HttpResponseMessage> SendAsync(
132172
HttpRequestMessage request, CancellationToken cancellationToken)
133173
=> Task.FromResult(send(request));
134174
}
175+
176+
private sealed class NonSeekableMemoryStream(byte[] buffer) : MemoryStream(buffer)
177+
{
178+
public override bool CanSeek => false;
179+
}
135180
}

DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs

Lines changed: 111 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System.Diagnostics;
2+
using System.Net;
3+
using System.Text;
24
using DebugProbe.AspNetCore.Internal.Utils;
35
using DebugProbe.AspNetCore.Models;
46
using DebugProbe.AspNetCore.Options;
@@ -33,13 +35,13 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
3335
{
3436
var response = await base.SendAsync(request, cancellationToken);
3537

36-
await CaptureRequest(request, response, null, started.ElapsedMilliseconds);
38+
await CaptureRequest(request, response, null, started.ElapsedMilliseconds, cancellationToken);
3739

3840
return response;
3941
}
4042
catch (Exception ex)
4143
{
42-
await CaptureRequest(request, null, ex, started.ElapsedMilliseconds);
44+
await CaptureRequest(request, null, ex, started.ElapsedMilliseconds, cancellationToken);
4345

4446
throw;
4547
}
@@ -48,7 +50,12 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
4850
/// <summary>
4951
/// Captures outgoing request details and stores them in the active DebugProbe entry.
5052
/// </summary>
51-
private async Task CaptureRequest(HttpRequestMessage request, HttpResponseMessage? response, Exception? exception, long durationMs)
53+
private async Task CaptureRequest(
54+
HttpRequestMessage request,
55+
HttpResponseMessage? response,
56+
Exception? exception,
57+
long durationMs,
58+
CancellationToken cancellationToken)
5259
{
5360
if (!TryGetActiveEntry(out var entry))
5461
{
@@ -76,9 +83,9 @@ private async Task CaptureRequest(HttpRequestMessage request, HttpResponseMessag
7683
ResponseHeaders = response != null ? response.Headers.ToDictionary(x => x.Key, x => RedactionUtils.RedactHeader(x.Key, string.Join(", ", x.Value), _options)) : []
7784
};
7885

79-
outgoing.RequestBody = await CaptureBodyAsync(request.Content);
86+
outgoing.RequestBody = (await CaptureBodyAsync(request.Content, cancellationToken)).Body;
8087

81-
outgoing.ResponseBody = await CaptureBodyAsync(response?.Content);
88+
outgoing.ResponseBody = await CaptureResponseBodyAsync(response, cancellationToken);
8289

8390
entry.OutgoingRequests.Add(outgoing);
8491
}
@@ -100,7 +107,25 @@ private bool TryGetActiveEntry(out DebugEntry entry)
100107
return true;
101108
}
102109

103-
private async Task<string> CaptureBodyAsync(HttpContent? content)
110+
private async Task<string> CaptureResponseBodyAsync(HttpResponseMessage? response, CancellationToken cancellationToken)
111+
{
112+
var content = response?.Content;
113+
if (content == null)
114+
{
115+
return string.Empty;
116+
}
117+
118+
var result = await CaptureBodyAsync(content, cancellationToken);
119+
120+
if (result.BytesRead.Length > 0)
121+
{
122+
response!.Content = new PrefixReplayHttpContent(content, result.Stream, result.BytesRead);
123+
}
124+
125+
return result.Body;
126+
}
127+
128+
private async Task<BodyCaptureResult> CaptureBodyAsync(HttpContent? content, CancellationToken cancellationToken)
104129
{
105130
if (content == null ||
106131
!HttpContentUtils.IsTextContent(content.Headers.ContentType?.MediaType))
@@ -115,24 +140,100 @@ private async Task<string> CaptureBodyAsync(HttpContent? content)
115140
var buffer = new byte[limit + 1];
116141
var totalRead = 0;
117142

118-
var stream = await content.ReadAsStreamAsync();
143+
var stream = await content.ReadAsStreamAsync(cancellationToken);
119144

120145
int bytesRead;
121146
while (totalRead < buffer.Length &&
122-
(bytesRead = await stream.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead))) > 0)
147+
(bytesRead = await stream.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken)) > 0)
123148
{
124149
totalRead += bytesRead;
125150
}
126151

127152
var truncated = totalRead > limit;
128-
var encoding = System.Text.Encoding.UTF8;
153+
var encoding = GetEncoding(content);
129154
var body = encoding.GetString(buffer, 0, Math.Min(totalRead, limit));
130155

131156
if (truncated)
132157
{
133158
body += "[truncated]";
134159
}
135160

136-
return JsonUtils.Format(RedactionUtils.RedactJsonFields(body, _options));
161+
return new BodyCaptureResult(
162+
JsonUtils.Format(RedactionUtils.RedactJsonFields(body, _options)),
163+
stream,
164+
buffer[..totalRead]);
165+
}
166+
167+
private static Encoding GetEncoding(HttpContent content)
168+
{
169+
var charset = content.Headers.ContentType?.CharSet;
170+
if (string.IsNullOrWhiteSpace(charset))
171+
{
172+
return Encoding.UTF8;
173+
}
174+
175+
try
176+
{
177+
return Encoding.GetEncoding(charset.Trim('"'));
178+
}
179+
catch
180+
{
181+
return Encoding.UTF8;
182+
}
183+
}
184+
185+
private readonly record struct BodyCaptureResult(string Body, Stream Stream, byte[] BytesRead)
186+
{
187+
public static implicit operator BodyCaptureResult(string body) => new(body, Stream.Null, []);
188+
}
189+
190+
private sealed class PrefixReplayHttpContent : HttpContent
191+
{
192+
private readonly HttpContent _innerContent;
193+
194+
private readonly Stream _remainingStream;
195+
196+
private readonly byte[] _prefix;
197+
198+
public PrefixReplayHttpContent(HttpContent innerContent, Stream remainingStream, byte[] prefix)
199+
{
200+
_innerContent = innerContent;
201+
_remainingStream = remainingStream;
202+
_prefix = prefix;
203+
204+
foreach (var header in innerContent.Headers)
205+
{
206+
Headers.TryAddWithoutValidation(header.Key, header.Value);
207+
}
208+
}
209+
210+
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context)
211+
{
212+
await stream.WriteAsync(_prefix);
213+
await _remainingStream.CopyToAsync(stream);
214+
}
215+
216+
protected override bool TryComputeLength(out long length)
217+
{
218+
if (_innerContent.Headers.ContentLength is { } contentLength)
219+
{
220+
length = contentLength;
221+
return true;
222+
}
223+
224+
length = 0;
225+
return false;
226+
}
227+
228+
protected override void Dispose(bool disposing)
229+
{
230+
if (disposing)
231+
{
232+
_remainingStream.Dispose();
233+
_innerContent.Dispose();
234+
}
235+
236+
base.Dispose(disposing);
237+
}
137238
}
138239
}

0 commit comments

Comments
 (0)