Skip to content

Commit ed88f03

Browse files
committed
docs: Point streaming cap references at the new setting
Javadoc and README now name maxDecompressedStreamingRequestBytes where streamed bodies are concerned. Adds an IT holding a streaming extra route to the streaming cap, with the setters in reverse order.
1 parent e7fb1c2 commit ed88f03

7 files changed

Lines changed: 46 additions & 18 deletions

File tree

‎README.md‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,7 @@ OpenApiServer.builder()
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
501501
[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.
502+
[can be set separately](#a-separate-cap-for-streamed-bodies), past 2 GiB.
503503

504504
**Responses.** A body is gzipped when the client sends `Accept-Encoding: gzip`, the media type is
505505
text-shaped (`text/*`, `application/json`, `application/xml`, `application/yaml`, and the `+json` /
@@ -542,7 +542,8 @@ OpenApiServer.builder()
542542

543543
The client's weights pick the coding; on a tie, registered codings win over gzip, in registration
544544
order. `decode` and `encode` wrap streams rather than whole bodies, so a decoder that reads lazily
545-
is held to `maxDecompressedRequestBytes` without doing anything itself. `requestContentCoding` and
545+
is held to `maxDecompressedRequestBytes` (or, for a streaming handler,
546+
`maxDecompressedStreamingRequestBytes`) without doing anything itself. `requestContentCoding` and
546547
`responseContentCoding` register one direction only; a request coded with a response-only coding
547548
gets 415. Tokens must be lower-case, and `gzip`, `x-gzip`, `identity` and `*` are reserved.
548549

@@ -1036,7 +1037,7 @@ What changes:
10361037
is closed rather than reused.
10371038
- **A `Content-Encoding` is decoded as the handler reads**, capped by
10381039
[`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+
[streaming cap](#a-separate-cap-for-streamed-bodies) is set. Going over the cap, or a body that
10401041
fails to decode, surfaces from `read` as a `BadRequestException` (413 or 400). The client
10411042
dropping the connection surfaces as the plain `IOException` it would be for an uncoded body.
10421043
Let it propagate and the `ExceptionHandler` renders it as usual. By then the handler may
@@ -1054,16 +1055,19 @@ Tests can build a streaming request directly with `Request.streaming(InputStream
10541055
buffered requests, `bodyStream()` returns a fresh stream over `bytes()`, so a helper that reads a
10551056
stream works for both.
10561057

1057-
### Raising the cap for streamed bodies
1058+
### A separate cap for streamed bodies
10581059

10591060
`maxDecompressedRequestBytes` bounds buffered bodies too, which are held in a `byte[]`, so it
10601061
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+
any positive `long` — higher than the buffered one, or lower:
10621063

10631064
```java
10641065
OpenApiServer.builder()
1066+
.spec(spec)
1067+
.handlers(handlers)
10651068
.maxDecompressedRequestBytes(32 * 1024 * 1024) // buffered: 32 MiB
10661069
.maxDecompressedStreamingRequestBytes(50L * 1024 * 1024 * 1024) // streamed: 50 GiB
1070+
.build();
10671071
```
10681072

10691073
Left unset, streamed bodies share `maxDecompressedRequestBytes`. It covers decoded bodies only;

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,5 +194,6 @@ Spec: "Streaming decompression cap". Lifts the 2 GiB ceiling on decoded streams
194194
- [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.
195195
- [x] **Step 2:** `Builder.maxDecompressedStreamingRequestBytes(long)` — any positive `long`; unset falls back to `maxDecompressedRequestBytes` at `build()`. Javadoc on both setters.
196196
- [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.
197+
- [x] **Step 4:** README "A separate cap for streamed bodies", cross-links from "Content encoding", the streaming bullets and "Raw bodies"; spec section and constraint; this task.
198198
- [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.
199+
- [x] **Step 6: Review (Opus, SHIP WITH FIXES, docs only).** Point the `StreamingRequestHandler`, `ContentCoding` and `contentCoding` javadoc and the README coding paragraph at the streaming cap; rename the README section to "A separate cap for streamed bodies" (it can be lower too) and complete its snippet; rewrap the spec paragraph. IT: a streaming extra route is held to the streaming cap, with the setters in reverse order.

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,11 @@ Buffered operations keep their exact order (body read, then 404/405, then valida
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
6767
against the streaming cap (`maxDecompressedStreamingRequestBytes`, defaulting to
68-
`maxDecompressedRequestBytes`; see "Streaming decompression cap"). Exceeding the cap throws `BadRequestException(413)` from
69-
`read`; a decoder failure throws `BadRequestException(400)`. Both propagate out of the handler to
70-
the `ExceptionHandler`. The header view hides `Content-Encoding` and `Content-Length` for a decoded
71-
body, since the decoded length is unknown up front. Identity bodies are passed through uncapped,
72-
matching the buffered path.
68+
`maxDecompressedRequestBytes`; see "Streaming decompression cap"). Exceeding the cap throws
69+
`BadRequestException(413)` from `read`; a decoder failure throws `BadRequestException(400)`. Both
70+
propagate out of the handler to the `ExceptionHandler`. The header view hides `Content-Encoding`
71+
and `Content-Length` for a decoded body, since the decoded length is unknown up front. Identity
72+
bodies are passed through uncapped, matching the buffered path.
7373

7474
### Boot validation
7575

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@
1717
*
1818
* <p>Both methods wrap a stream rather than convert a whole body: return a stream that codes as it
1919
* is read or written. For {@link #decode} this is what bounds the work — the server reads at most
20-
* {@code maxDecompressedRequestBytes} from the stream you return, so a lazy decoder is protected
21-
* from decompression bombs without doing anything, while one that expands the whole body up front
22-
* has already spent what that limit exists to protect.
20+
* {@code maxDecompressedRequestBytes} (or {@code maxDecompressedStreamingRequestBytes} for a
21+
* streaming handler) from the stream you return, so a lazy decoder is protected from decompression
22+
* bombs without doing anything, while one that expands the whole body up front has already spent
23+
* what that limit exists to protect.
2324
*/
2425
public interface ContentCoding {
2526

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,8 @@ public Builder minCompressibleResponseBytes(long minCompressibleResponseBytes) {
462462
* Registers a content coding the server decodes on requests and applies to responses, alongside
463463
* the built-in gzip. When a client weights several codings equally, the ones registered here
464464
* win over gzip, in registration order; otherwise the client's weights decide. A decoded body
465-
* is still held to {@link #maxDecompressedRequestBytes(long)}.
465+
* is still held to {@link #maxDecompressedRequestBytes(long)}, or to {@link
466+
* #maxDecompressedStreamingRequestBytes(long)} for a streaming handler.
466467
*
467468
* @throws IllegalArgumentException if a token or alias is not a lower-case RFC 9110 token, or
468469
* is one of the reserved {@code gzip}, {@code x-gzip}, {@code identity} or {@code *}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@
2525
* through {@link Request#bodyStream()}, once, before {@link #handle(Request)} returns.
2626
*
2727
* <p>A registered {@code Content-Encoding} is decoded as the handler reads, and the decoded size is
28-
* held to {@link OpenApiServer.Builder#maxDecompressedRequestBytes(long)}. A body that exceeds it,
29-
* or fails to decode, surfaces from the stream's {@code read} as a {@link BadRequestException} (413
30-
* or 400) — possibly after the handler has already passed earlier bytes on. An uncoded body has no
28+
* held to {@link OpenApiServer.Builder#maxDecompressedStreamingRequestBytes(long)}, which defaults
29+
* to {@link OpenApiServer.Builder#maxDecompressedRequestBytes(long)}. A body that exceeds it, or
30+
* fails to decode, surfaces from the stream's {@code read} as a {@link BadRequestException} (413 or
31+
* 400) — possibly after the handler has already passed earlier bytes on. An uncoded body has no
3132
* size limit. To receive a coded body exactly as sent instead, see {@link #raw}.
3233
*
3334
* <p>Concurrent streaming requests can be capped server-wide with {@link

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,26 @@ void extraRouteStreamsItsBody() throws Exception {
298298
assertThat(response.headers().firstValue(RECEIVED)).hasValue("2048");
299299
}
300300

301+
@Test
302+
void streamingExtraRouteIsHeldToTheStreamingCapWhicheverSetterComesFirst() throws Exception {
303+
start(
304+
Map.of(),
305+
b ->
306+
b.extraRoute("/ingest", counting())
307+
.maxDecompressedStreamingRequestBytes(1024)
308+
.maxDecompressedRequestBytes(1024 * 1024));
309+
URI ingest = URI.create("http://localhost:%d/ingest".formatted(server.listenPort()));
310+
311+
var response =
312+
send(
313+
HttpRequest.newBuilder()
314+
.uri(ingest)
315+
.header("Content-Encoding", "gzip")
316+
.POST(BodyPublishers.ofByteArray(gzip(new byte[64 * 1024]))));
317+
318+
assertThat(response.statusCode()).isEqualTo(HTTP_ENTITY_TOO_LARGE);
319+
}
320+
301321
@Test
302322
void streamingHandlerOnAnOperationWithoutARequestBodyFailsAtBoot() {
303323
OpenApiServer.Builder builder =

0 commit comments

Comments
 (0)