Skip to content

Commit e7fb1c2

Browse files
committed
feat: Separate decompression cap for streamed bodies
Builder.maxDecompressedStreamingRequestBytes(long) caps the decoded size of a body read by a StreamingRequestHandler with any positive long, so decoded streams are no longer held to the 2 GiB that bounds a buffered body. Unset, it defaults to maxDecompressedRequestBytes.
1 parent a3dfc7a commit e7fb1c2

9 files changed

Lines changed: 183 additions & 20 deletions

File tree

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Key abstractions:
4141
- `com.retailsvc.http.validate.DefaultValidator` — single class using `switch` pattern-match over `Schema` subtypes. Validation failures produce RFC 9457 `application/problem+json` 400 responses.
4242
- `com.retailsvc.http.internal.Router` — two indexes: exact path map and templated path list. Resolves `operationId` + extracted path variables for each request.
4343
- `TypeMapper` — per-media-type request parsing and response writing; registered via `Builder.bodyMapper(...)`, with `GsonTypeMapper` auto-registered when Gson is on the classpath.
44-
- `com.retailsvc.http.Request` — an immutable record-like carrier built from primitives (body bytes, path parameters, raw query string, a header lookup function), never the `HttpExchange`. `bytes()` returns the decoded body, `parsed()` the object produced by the `TypeMapper`. A handler that implements `StreamingRequestHandler` instead gets the body unread: `RequestPreparationFilter` routes first, validates parameters, peeks one byte, and binds `Request.streaming(...)`, whose `bodyStream()` decodes lazily (`internal/DecodingInputStream`) under the same size cap, or not at all when the handler's `decodeContent()` is false; `bytes()` / `parsed()` throw. `internal/StreamingLimit` optionally caps concurrent streaming requests server-wide (503 + `Retry-After`). `internal/RequestContentType` matches `Content-Type` against `requestBody.content` for both paths.
44+
- `com.retailsvc.http.Request` — an immutable record-like carrier built from primitives (body bytes, path parameters, raw query string, a header lookup function), never the `HttpExchange`. `bytes()` returns the decoded body, `parsed()` the object produced by the `TypeMapper`. A handler that implements `StreamingRequestHandler` instead gets the body unread: `RequestPreparationFilter` routes first, validates parameters, peeks one byte, and binds `Request.streaming(...)`, whose `bodyStream()` decodes lazily (`internal/DecodingInputStream`) under its own `long` size cap (`maxDecompressedStreamingRequestBytes`, defaulting to the buffered one), or not at all when the handler's `decodeContent()` is false; `bytes()` / `parsed()` throw. `internal/StreamingLimit` optionally caps concurrent streaming requests server-wide (503 + `Retry-After`). `internal/RequestContentType` matches `Content-Type` against `requestBody.content` for both paths.
4545
- `com.retailsvc.http.ContentCoding` — a pluggable HTTP content coding. gzip is built in (`internal/GzipCoding`); callers register others on the builder, held per direction in `internal/ContentCodings`. `RequestBodyReader` decodes requests under the size cap and `ResponseRenderer` codes responses. See the README's "Content encoding" section for the policy.
4646

4747
## Conventions

‎README.md‎

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,8 @@ OpenApiServer.builder()
498498

499499
Note this bounds the *inflated* size of a coded body, whatever the coding. It is not a request size
500500
limit — a body that arrives uncompressed is read in full, as it always has been. For a
501-
[streaming handler](#streaming-request-bodies), the cap is enforced as the handler reads.
501+
[streaming handler](#streaming-request-bodies), the cap is enforced as the handler reads, and
502+
[can be set separately](#raising-the-cap-for-streamed-bodies), past 2 GiB.
502503

503504
**Responses.** A body is gzipped when the client sends `Accept-Encoding: gzip`, the media type is
504505
text-shaped (`text/*`, `application/json`, `application/xml`, `application/yaml`, and the `+json` /
@@ -1033,15 +1034,15 @@ What changes:
10331034
handler returns, the response is sent and the exchange closed. A body the handler leaves unread
10341035
is drained up to 64 KiB (the JDK's `sun.net.httpserver.drainAmount`); past that, the connection
10351036
is closed rather than reused.
1036-
- **A `Content-Encoding` is decoded as the handler reads**, still capped by
1037-
[`maxDecompressedRequestBytes`](#content-encoding), which can't go above 2 GiB — use a
1038-
[raw body](#raw-bodies) to take larger coded uploads. Going over the cap, or a body that fails to
1039-
decode, surfaces from `read` as a `BadRequestException` (413 or 400). The client dropping the
1040-
connection surfaces as the plain `IOException` it would be for an uncoded body. Let it propagate and the
1041-
`ExceptionHandler` renders it as usual. By then the handler may already have passed earlier bytes
1042-
on, so write to storage in a way you can abandon, such as a resumable upload you finalise only
1043-
after the last read. The handler sees no `Content-Encoding` and no `Content-Length` for a
1044-
decoded body.
1037+
- **A `Content-Encoding` is decoded as the handler reads**, capped by
1038+
[`maxDecompressedRequestBytes`](#content-encoding) unless a
1039+
[streaming cap](#raising-the-cap-for-streamed-bodies) is set. Going over the cap, or a body that
1040+
fails to decode, surfaces from `read` as a `BadRequestException` (413 or 400). The client
1041+
dropping the connection surfaces as the plain `IOException` it would be for an uncoded body.
1042+
Let it propagate and the `ExceptionHandler` renders it as usual. By then the handler may
1043+
already have passed earlier bytes on, so write to storage in a way you can abandon, such as a
1044+
resumable upload you finalise only after the last read. The handler sees no `Content-Encoding`
1045+
and no `Content-Length` for a decoded body.
10451046
- **An uncoded body has no size limit.** Bound it yourself if you need to, for example by counting
10461047
as you read, and bound how many run at once with
10471048
[`maxConcurrentStreamingRequests`](#limiting-concurrent-streaming-requests).
@@ -1053,6 +1054,21 @@ Tests can build a streaming request directly with `Request.streaming(InputStream
10531054
buffered requests, `bodyStream()` returns a fresh stream over `bytes()`, so a helper that reads a
10541055
stream works for both.
10551056

1057+
### Raising the cap for streamed bodies
1058+
1059+
`maxDecompressedRequestBytes` bounds buffered bodies too, which are held in a `byte[]`, so it
1060+
can't go above 2 GiB. A streamed body is never held in memory, so it can have a cap of its own,
1061+
any positive `long`:
1062+
1063+
```java
1064+
OpenApiServer.builder()
1065+
.maxDecompressedRequestBytes(32 * 1024 * 1024) // buffered: 32 MiB
1066+
.maxDecompressedStreamingRequestBytes(50L * 1024 * 1024 * 1024) // streamed: 50 GiB
1067+
```
1068+
1069+
Left unset, streamed bodies share `maxDecompressedRequestBytes`. It covers decoded bodies only;
1070+
uncoded and [raw](#raw-bodies) bodies have no cap either way.
1071+
10561072
### Raw bodies
10571073

10581074
To store or forward a body exactly as the client sent it, wrap the handler in
@@ -1063,8 +1079,8 @@ To store or forward a body exactly as the client sent it, wrap the handler in
10631079
```
10641080

10651081
The handler then reads the coded bytes. `Content-Encoding` and `Content-Length` are visible as
1066-
sent. `maxDecompressedRequestBytes` doesn't apply, and a coding the server doesn't know is passed
1067-
on instead of being answered 415.
1082+
sent. Neither decompression cap applies, and a coding the server doesn't know is passed on
1083+
instead of being answered 415.
10681084

10691085
### Limiting concurrent streaming requests
10701086

‎docs/superpowers/plans/2026-10-08-streaming-request-bodies.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
**Goal:** Let a handler opt into reading the request body as it arrives, via a `StreamingRequestHandler` marker type, so memory per request no longer grows with the body — while routing, parameter validation, security, interceptors and error rendering keep working.
66

7-
**Architecture:** `RequestPreparationFilter` routes before reading the body. For an operation whose handler is a `StreamingRequestHandler`, it opens the body through a new `RequestBodyReader.stream(...)` instead of `read(...)`, validates parameters, peeks one byte to apply the `required` / `Content-Type` checks, and binds a `Request` built with `Request.streaming(...)`. Coded bodies are decoded lazily by an internal `DecodingInputStream` that enforces `maxDecompressedRequestBytes` as the handler reads. `ExtrasRouter` takes the same split for extra routes. Buffered operations keep their exact behaviour and order.
7+
**Architecture:** `RequestPreparationFilter` routes before reading the body. For an operation whose handler is a `StreamingRequestHandler`, it opens the body through a new `RequestBodyReader.stream(...)` instead of `read(...)`, validates parameters, peeks one byte to apply the `required` / `Content-Type` checks, and binds a `Request` built with `Request.streaming(...)`. Coded bodies are decoded lazily by an internal `DecodingInputStream` that enforces the decompression cap as the handler reads (`maxDecompressedStreamingRequestBytes`, defaulting to `maxDecompressedRequestBytes`; Task 15). `ExtrasRouter` takes the same split for extra routes. Buffered operations keep their exact behaviour and order.
88

99
**Additions (Tasks 9–12), review fixes (Task 13):** Content-Type matching with media-type ranges and a missing-header default, `Request.headers()`, raw (undecoded) streaming bodies, and a server-wide cap on concurrent streaming requests. See the spec's "Additions" section.
1010

@@ -186,3 +186,13 @@ Two independent reviews (Opus, Fable) returned SHIP WITH FIXES.
186186
- [x] **Step 3:** SonarLint sweep (`sonar_analyze_file`) over the changed main and test files; fix new findings. The `java:S9391` hit in `OpenApiServer.validateHandlerWiring` predates this change.
187187
- [x] **Step 4:** Note `StreamingRequestHandler` in `CLAUDE.md`'s architecture section.
188188
- [x] **Step 5:** Commit on `feat/streaming-request-bodies`: `feat: Support streaming request bodies` (signed).
189+
190+
## Task 15: Streaming decompression cap
191+
192+
Spec: "Streaming decompression cap". Lifts the 2 GiB ceiling on decoded streams without touching the buffered path.
193+
194+
- [x] **Step 1:** `RequestBodyReader`: three-argument constructor `(maxDecompressedBytes, maxStreamedDecompressedBytes, decoders)`, both validated positive; the two-argument constructor delegates with the same cap. `stream` passes the streamed cap to `DecodingInputStream`; `read` is unchanged.
195+
- [x] **Step 2:** `Builder.maxDecompressedStreamingRequestBytes(long)` — any positive `long`; unset falls back to `maxDecompressedRequestBytes` at `build()`. Javadoc on both setters.
196+
- [x] **Step 3:** Tests. Unit: streamed body held to its own cap, buffered body keeps its cap when the streamed one is higher, constructor rejects a non-positive streamed cap; builder accepts `Integer.MAX_VALUE + 1` and `Long.MAX_VALUE`, rejects 0 and -1. IT: a gzip body past the buffered cap streams when the streaming cap is higher; one past the streaming cap is 413.
197+
- [x] **Step 4:** README "Raising the cap for streamed bodies", cross-links from "Content encoding", the streaming bullets and "Raw bodies"; spec section and constraint; this task.
198+
- [x] **Step 5:** `pre-commit run`, `mvn verify`, SonarLint on changed files; commit `feat: Separate decompression cap for streamed bodies` (signed) and push to PR #131.

‎docs/superpowers/specs/2026-10-08-streaming-request-bodies-design.md‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ Buffered operations keep their exact order (body read, then 404/405, then valida
6464

6565
A coded body is decoded lazily by `DecodingInputStream`, which opens the decoder once the first
6666
byte arrives (so an empty coded body reads as empty, as when buffered) and counts decoded bytes
67-
against `maxDecompressedRequestBytes`. Exceeding the cap throws `BadRequestException(413)` from
67+
against the streaming cap (`maxDecompressedStreamingRequestBytes`, defaulting to
68+
`maxDecompressedRequestBytes`; see "Streaming decompression cap"). Exceeding the cap throws `BadRequestException(413)` from
6869
`read`; a decoder failure throws `BadRequestException(400)`. Both propagate out of the handler to
6970
the `ExceptionHandler`. The header view hides `Content-Encoding` and `Content-Length` for a decoded
7071
body, since the decoded length is unknown up front. Identity bodies are passed through uncapped,
@@ -135,6 +136,23 @@ binding and the extras router.
135136
`maxConnections` would also shed health probes and cheap API calls under an upload burst, which
136137
is the opposite of what shedding is for.
137138

139+
### Streaming decompression cap
140+
141+
`maxDecompressedRequestBytes` bounds both paths, but the buffered one decodes into a `byte[]`, so
142+
the builder rejects values above `Integer.MAX_VALUE`. That needlessly held decoded streams to
143+
2 GiB, since `DecodingInputStream` counts in a `long` and holds nothing.
144+
145+
- `Builder.maxDecompressedStreamingRequestBytes(long)` sets the cap for bodies decoded as a
146+
streaming handler reads them; any positive `long`, else `IllegalArgumentException`.
147+
- Unset, it defaults to `maxDecompressedRequestBytes`, so existing servers behave as before.
148+
- `RequestBodyReader` gains a three-argument constructor `(maxDecompressedBytes,
149+
maxStreamedDecompressedBytes, decoders)`; the two-argument one passes the same cap twice.
150+
`read` keeps the first, `stream` uses the second.
151+
- Uncoded and raw bodies stay uncapped. Additive only: no existing signature or default changes.
152+
153+
A single setting that the buffered path silently clamps was rejected: one value would mean
154+
different things on the two paths.
155+
138156
## Constraints
139157

140158
- The stream must be read inside `handle()`, on the request thread. After the handler returns,
@@ -148,8 +166,9 @@ binding and the extras router.
148166
`Retry-After`.
149167
- `Expect: 100-continue` is answered by the JDK server before any filter runs, so a shed client
150168
may still send its body; the server drains up to 64 KiB of it, then closes the connection.
151-
- The decoded-size cap can't exceed 2 GiB (`maxDecompressedRequestBytes` is bounded by the
152-
buffered path's `byte[]`); raw bodies have no cap.
169+
- `maxDecompressedRequestBytes` can't exceed 2 GiB (bounded by the buffered path's `byte[]`).
170+
Streamed bodies take their own `long` cap, so this no longer limits them; raw bodies have no
171+
cap.
153172
- `DecodingInputStream` turns only the coding's failures into a 400. A connection failure is
154173
tagged as it leaves the raw stream and rethrown unchanged, so a client disconnect is an
155174
`IOException`, as for an uncoded body.

‎src/main/java/com/retailsvc/http/OpenApiServer.java‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ public static final class Builder {
256256
private final Map<String, SchemeValidator> securityValidators = new LinkedHashMap<>();
257257
private boolean externalAuth = false;
258258
private long maxDecompressedRequestBytes = RequestBodyReader.DEFAULT_MAX_DECOMPRESSED_BYTES;
259+
private Long maxDecompressedStreamingRequestBytes;
259260
private long minCompressibleResponseBytes = ResponseRenderer.DEFAULT_MIN_COMPRESSIBLE_BYTES;
260261
private final List<ContentCoding> requestCodings = new ArrayList<>();
261262
private final List<ContentCoding> responseCodings = new ArrayList<>();
@@ -408,7 +409,9 @@ public Builder https(Path certificateChainPem, Path privateKeyPem) {
408409
* Ceiling on the inflated size of a gzip request body, 10 MiB by default. A compressed payload
409410
* can expand by orders of magnitude, so this bounds what a single request may allocate;
410411
* exceeding it fails the request with 413. Bodies that arrive uncompressed are not affected.
411-
* The cap holds for a {@link StreamingRequestHandler} too, enforced as the handler reads.
412+
* The cap holds for a {@link StreamingRequestHandler} too, enforced as the handler reads,
413+
* unless {@link #maxDecompressedStreamingRequestBytes(long)} sets one of its own. The body is
414+
* held in a {@code byte[]}, so this can't exceed {@link Integer#MAX_VALUE}.
412415
*/
413416
public Builder maxDecompressedRequestBytes(long maxDecompressedRequestBytes) {
414417
if (maxDecompressedRequestBytes <= 0 || maxDecompressedRequestBytes > Integer.MAX_VALUE) {
@@ -422,6 +425,23 @@ public Builder maxDecompressedRequestBytes(long maxDecompressedRequestBytes) {
422425
return this;
423426
}
424427

428+
/**
429+
* Ceiling on the decoded size of a coded request body read by a {@link
430+
* StreamingRequestHandler}, enforced as the handler reads; exceeding it fails the read with
431+
* 413. Defaults to {@link #maxDecompressedRequestBytes(long)}. The body is never held in
432+
* memory, so any positive {@code long} is accepted, past the 2 GiB that bounds a buffered body.
433+
* Bodies that arrive uncompressed, and raw bodies, are not affected.
434+
*/
435+
public Builder maxDecompressedStreamingRequestBytes(long maxDecompressedStreamingRequestBytes) {
436+
if (maxDecompressedStreamingRequestBytes <= 0) {
437+
throw new IllegalArgumentException(
438+
"maxDecompressedStreamingRequestBytes must be positive, got "
439+
+ maxDecompressedStreamingRequestBytes);
440+
}
441+
this.maxDecompressedStreamingRequestBytes = maxDecompressedStreamingRequestBytes;
442+
return this;
443+
}
444+
425445
/**
426446
* Smallest response body worth compressing, 1 KiB by default. Below this, the coding costs more
427447
* than it saves. Set it to 0 to compress every compressible body, or high enough to exceed any
@@ -552,7 +572,12 @@ public OpenApiServer build() throws IOException {
552572
extras,
553573
externalAuth,
554574
List.copyOf(afterHooks),
555-
new RequestBodyReader(maxDecompressedRequestBytes, codings.decoders()),
575+
new RequestBodyReader(
576+
maxDecompressedRequestBytes,
577+
maxDecompressedStreamingRequestBytes != null
578+
? maxDecompressedStreamingRequestBytes
579+
: maxDecompressedRequestBytes,
580+
codings.decoders()),
556581
new ResponseRenderer(resolved, minCompressibleResponseBytes, codings.encoders()),
557582
streamingLimit);
558583
int resolvedPort = resolvePort();

‎src/main/java/com/retailsvc/http/internal/RequestBodyReader.java‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,34 @@ public final class RequestBodyReader {
3434
private static final String CONTENT_LENGTH = "Content-Length";
3535

3636
private final long maxDecompressedBytes;
37+
private final long maxStreamedDecompressedBytes;
3738
private final int readLimit;
3839
private final Map<String, ContentCoding> decoders;
3940

41+
/** A reader holding buffered and streamed bodies to the same cap. */
4042
public RequestBodyReader(long maxDecompressedBytes, Map<String, ContentCoding> decoders) {
43+
this(maxDecompressedBytes, maxDecompressedBytes, decoders);
44+
}
45+
46+
/**
47+
* A reader with separate caps: {@code maxDecompressedBytes} for a body read in full, {@code
48+
* maxStreamedDecompressedBytes} for one decoded as a streaming handler reads it. Only the first
49+
* is bounded by what fits in a {@code byte[]}.
50+
*/
51+
public RequestBodyReader(
52+
long maxDecompressedBytes,
53+
long maxStreamedDecompressedBytes,
54+
Map<String, ContentCoding> decoders) {
4155
if (maxDecompressedBytes <= 0) {
4256
throw new IllegalArgumentException(
4357
"maxDecompressedBytes must be positive, got " + maxDecompressedBytes);
4458
}
59+
if (maxStreamedDecompressedBytes <= 0) {
60+
throw new IllegalArgumentException(
61+
"maxStreamedDecompressedBytes must be positive, got " + maxStreamedDecompressedBytes);
62+
}
4563
this.maxDecompressedBytes = maxDecompressedBytes;
64+
this.maxStreamedDecompressedBytes = maxStreamedDecompressedBytes;
4665
this.readLimit = (int) Math.min(maxDecompressedBytes, Integer.MAX_VALUE - 1L) + 1;
4766
this.decoders = Map.copyOf(decoders);
4867
}
@@ -95,7 +114,7 @@ public Streamed stream(HttpExchange exchange, boolean decode) {
95114
case Identity _ -> new Streamed(raw, copy(headers));
96115
case Coded(ContentCoding coding) ->
97116
new Streamed(
98-
new DecodingInputStream(coding, raw, maxDecompressedBytes),
117+
new DecodingInputStream(coding, raw, maxStreamedDecompressedBytes),
99118
decodedHeaders(headers, null));
100119
case Unsupported _ ->
101120
throw new BadRequestException(

‎src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,20 @@ void rejectsNonPositiveMaxDecompressedRequestBytes() {
6565
.hasMessageContaining("-1");
6666
}
6767

68+
@Test
69+
void streamingDecompressionCapAcceptsAnyPositiveLong() {
70+
OpenApiServer.Builder b = OpenApiServer.builder();
71+
72+
assertThat(b.maxDecompressedStreamingRequestBytes(Integer.MAX_VALUE + 1L)).isSameAs(b);
73+
assertThat(b.maxDecompressedStreamingRequestBytes(Long.MAX_VALUE)).isSameAs(b);
74+
assertThatThrownBy(() -> b.maxDecompressedStreamingRequestBytes(0))
75+
.isInstanceOf(IllegalArgumentException.class)
76+
.hasMessageContaining("0");
77+
assertThatThrownBy(() -> b.maxDecompressedStreamingRequestBytes(-1))
78+
.isInstanceOf(IllegalArgumentException.class)
79+
.hasMessageContaining("-1");
80+
}
81+
6882
@Test
6983
void rejectsInvalidMaxConcurrentStreamingRequests() {
7084
OpenApiServer.Builder b = OpenApiServer.builder();

0 commit comments

Comments
 (0)