Choose the byte budget you actually enforce
Reading incrementally prevents the caller from assuming that an HTTP body fits in one convenient buffer, but it does not automatically impose a size limit. ReadBounded accepts an explicit maximum and counts bytes actually returned by the content stream. The sample sets that maximum to four and supplies two controlled bodies through a real offline handler. It requests ResponseHeadersRead so SendAsync does not use the ordinary response-content completion policy before the caller's reading loop. The experiment illustrates an application byte budget, not a complete production download service or a universal defense against resource exhaustion.
Trace the four-byte and oversized bodies
The small route returns the UTF-8 bytes for rune. ReadBounded receives four bytes, sees that the accumulated length remains within the maximum, stores them and eventually sees a zero result for its nonempty read request. The caller decodes the accepted bytes and prints rune. The large route contains oversize. After the first accepted block, another block would take the accumulated length above four, so InvalidDataException is thrown and the caller prints limit exceeded. Rejection happens before that over-budget block is written into the result stream.
Keep the response alive during streamed consumption
The HttpResponseMessage remains in scope while its content stream is opened, read and decoded. The stream has an inner using declaration, so it is disposed before the response that owns the content. ReadBounded owns its temporary MemoryStream and returns an independent byte array, avoiding a borrowed buffer after disposal. Its cancellation token reaches each ReadAsync call. In the baseline that token is CancellationToken.None; the signature preserves the ownership boundary for a caller that supplies cancellation. ResponseHeadersRead also means content consumption needs its own appropriate cancellation or deadline policy.
A small buffer is not a complete resource policy
The byte budget limits data accumulated by this reader. It does not prove that the underlying handler has not already buffered content, and this particular handler starts with an in-memory body. MemoryStream may reserve more capacity than its logical length, while ToArray creates another array. The four-byte working buffer also exists independently of the accepted payload. Do not describe the maximum as an exact process-memory bound. A compressed body, decoded text, parsed document, record collection and rendered output can each require additional, separately bounded resources.
Practice: test exact, empty and over-budget bodies
Add independent bodies of zero, exactly four and five bytes, with a maximum of four. Write the accepted byte lengths or rejection result before executing them. Then run an empty body and a one-byte body with a maximum of zero to check the inclusive boundary. Keep negative maximum as a distinct argument-rejection case. The hint is to compare logical bytes received rather than the capacity of either buffer. These cases should still behave correctly when a content stream omits or supplies an inaccurate Content-Length header.
Practice: retain the limit under short reads
Adapt the earlier capped stream to override the memory-based ReadAsync used here, honor cancellation and return a completed ValueTask around its capped memory read. Reusing the synchronous-only subclass unchanged can fall back to inherited asynchronous scheduling. Make each supported asynchronous read return at most two bytes. The same accepted payload and limit outcomes should remain unchanged, although the number of reads increases. Add a counter for reads without using it as the acceptance criterion. This tests that policy depends on total bytes rather than the number of chunks. For a further extension, introduce caller cancellation between controlled chunks and verify disposal and a canceled outcome without relying on a sleep or a public slow endpoint.
Review limits at each representation boundary
A useful streaming boundary names its units and its owner. Here the unit is bytes delivered by the content stream, the budget is an inclusive application maximum, and the response lifetime encloses consumption. The sample deliberately uses tiny data so each outcome can be predicted. A production adapter needs additional decisions about time, concurrency, decompression, encoding, parser depth and domain record counts. Keep those decisions distinct instead of treating one buffer size or a declared Content-Length as proof that every downstream representation is safe and bounded.
- Count actual returned bytes and reject before accumulating beyond the budget.
- A payload byte limit is not an exact allocation, time or decoded-model limit.
A working example
using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.IO;
using System.IO.Compression;
using System.Buffers.Binary;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using var client = new HttpClient(new BodyHandler());
foreach (string path in new[] { "small", "large" }) {
using var request = new HttpRequestMessage(HttpMethod.Get, "https://offline.invalid/" + path);
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, CancellationToken.None); response.EnsureSuccessStatusCode();
using Stream body = await response.Content.ReadAsStreamAsync(CancellationToken.None);
try { Console.WriteLine(Encoding.UTF8.GetString(await ReadBounded(body, 4, CancellationToken.None))); } catch (InvalidDataException) { Console.WriteLine("limit exceeded"); }
}
static async Task<byte[]> ReadBounded(Stream input, int maximum, CancellationToken token) {
if (maximum < 0) throw new ArgumentOutOfRangeException(nameof(maximum)); using var result = new MemoryStream(); byte[] buffer = new byte[4]; int count;
while ((count = await input.ReadAsync(buffer.AsMemory(), token)) != 0) { if (result.Length + count > maximum) throw new InvalidDataException("Body budget"); result.Write(buffer, 0, count); }
return result.ToArray();
}
sealed class BodyHandler : HttpMessageHandler {
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token) { token.ThrowIfCancellationRequested(); string body = request.RequestUri!.AbsolutePath == "/small" ? "rune" : "oversize"; return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes(body), writable: false)) }); }
}
Expected output
rune limit exceeded
Count actual body bytes, keep the response owned through reading, and pass the cancellation token through the loop.