Skip to content

Commit 33bab95

Browse files
committed
feat: Make the gzip size limits configurable
`maxDecompressedRequestBytes` moves the inflation ceiling off its 10 MiB default, for services whose legitimate payloads are larger. It is capped at Integer.MAX_VALUE because the inflated body is buffered into an array. `minimumGzipResponseBytes` moves the compression threshold off its 1 KiB default. Setting it to 0 compresses every compressible body; setting it above any response this server produces turns compression off, which is what a service behind a proxy that already terminates compression wants.
1 parent efe2362 commit 33bab95

3 files changed

Lines changed: 85 additions & 5 deletions

File tree

docs/plans/dynamic-discovering-piglet.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ as it completes.
278278

279279
### Task 4 — builder
280280

281-
- [ ] **Step 9** `OpenApiServerBuilderTest``maxDecompressedRequestBytesRejectsZero`,
281+
- [x] **Step 9** `OpenApiServerBuilderTest``maxDecompressedRequestBytesRejectsZero`,
282282
`maxDecompressedRequestBytesRejectsNegative`, `minimumGzipResponseBytesRejectsNegative`.
283283
Then add the two setters, the `HandlerConfig` fields and the `build()` wiring.
284284

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

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ record HandlerConfig(
6565
ExceptionHandler exceptionHandler,
6666
Map<String, RequestHandler> extras,
6767
boolean externalAuth,
68-
List<AfterResponseHook> afterHooks) {}
68+
List<AfterResponseHook> afterHooks,
69+
long maxDecompressedRequestBytes,
70+
long minimumGzipResponseBytes) {}
6971

7072
OpenApiServer(
7173
List<SpecBinding> bindings,
@@ -93,9 +95,10 @@ record HandlerConfig(
9395
this.httpServer = createHttpServer(socketAddress, sslContext);
9496
httpServer.setExecutor(newThreadPerTaskExecutor(ofVirtual().name("http-", 0).factory()));
9597

96-
ResponseRenderer renderer = new ResponseRenderer(bodyMappers);
98+
ResponseRenderer renderer =
99+
new ResponseRenderer(bodyMappers, handlerConfig.minimumGzipResponseBytes());
97100
RequestBodyReader bodyReader =
98-
new RequestBodyReader(RequestBodyReader.DEFAULT_MAX_DECOMPRESSED_BYTES);
101+
new RequestBodyReader(handlerConfig.maxDecompressedRequestBytes());
99102
boolean anyBindingAtRoot =
100103
wireBindings(
101104
httpServer,
@@ -278,6 +281,8 @@ public static final class Builder {
278281
private final LinkedHashMap<String, RequestHandler> extras = new LinkedHashMap<>();
279282
private final Map<String, SchemeValidator> securityValidators = new LinkedHashMap<>();
280283
private boolean externalAuth = false;
284+
private long maxDecompressedRequestBytes = RequestBodyReader.DEFAULT_MAX_DECOMPRESSED_BYTES;
285+
private long minimumGzipResponseBytes = ResponseRenderer.DEFAULT_MINIMUM_GZIP_BYTES;
281286
private final List<SpecBinding> bindings = new ArrayList<>();
282287

283288
private Builder() {}
@@ -427,6 +432,42 @@ public Builder https(Path certificateChainPem, Path privateKeyPem) {
427432
* stops immediately; positive values wait up to that many seconds for in-flight exchanges to
428433
* finish.
429434
*/
435+
/**
436+
* Ceiling on the inflated size of a gzip request body, 10 MiB by default. A compressed payload
437+
* can expand by orders of magnitude, so this bounds what a single request may allocate;
438+
* exceeding it fails the request with 413. Bodies that arrive uncompressed are not affected.
439+
*/
440+
public Builder maxDecompressedRequestBytes(long maxDecompressedRequestBytes) {
441+
if (maxDecompressedRequestBytes <= 0) {
442+
throw new IllegalArgumentException(
443+
"maxDecompressedRequestBytes must be positive, got " + maxDecompressedRequestBytes);
444+
}
445+
if (maxDecompressedRequestBytes > Integer.MAX_VALUE) {
446+
throw new IllegalArgumentException(
447+
"maxDecompressedRequestBytes must not exceed "
448+
+ Integer.MAX_VALUE
449+
+ ", got "
450+
+ maxDecompressedRequestBytes);
451+
}
452+
this.maxDecompressedRequestBytes = maxDecompressedRequestBytes;
453+
return this;
454+
}
455+
456+
/**
457+
* Smallest response body worth gzipping, 1 KiB by default. Below this, the coding costs more
458+
* than it saves. Set it to 0 to compress every compressible body, or high enough to exceed any
459+
* response this server produces to stop compressing altogether — useful when a proxy in front
460+
* already terminates compression.
461+
*/
462+
public Builder minimumGzipResponseBytes(long minimumGzipResponseBytes) {
463+
if (minimumGzipResponseBytes < 0) {
464+
throw new IllegalArgumentException(
465+
"minimumGzipResponseBytes must be non-negative, got " + minimumGzipResponseBytes);
466+
}
467+
this.minimumGzipResponseBytes = minimumGzipResponseBytes;
468+
return this;
469+
}
470+
430471
public Builder shutdownTimeoutSeconds(int shutdownTimeoutSeconds) {
431472
if (shutdownTimeoutSeconds < 0) {
432473
throw new IllegalArgumentException(
@@ -468,7 +509,9 @@ public OpenApiServer build() throws IOException {
468509
effectiveExceptionHandler,
469510
extras,
470511
externalAuth,
471-
List.copyOf(afterHooks));
512+
List.copyOf(afterHooks),
513+
maxDecompressedRequestBytes,
514+
minimumGzipResponseBytes);
472515
int resolvedPort = resolvePort();
473516
SSLContext sslContext =
474517
httpsCertChain != null ? PemSslContext.load(httpsCertChain, httpsPrivateKey) : null;

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.retailsvc.http;
22

33
import static java.util.Collections.emptyMap;
4+
import static org.assertj.core.api.Assertions.assertThat;
45
import static org.assertj.core.api.Assertions.assertThatThrownBy;
56
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
67

@@ -49,6 +50,42 @@ void rejectsExtraPathEqualToSpecBasePathAtBuildTime() {
4950
.hasMessageContaining("/api");
5051
}
5152

53+
@Test
54+
void rejectsNonPositiveMaxDecompressedRequestBytes() {
55+
OpenApiServer.Builder b = OpenApiServer.builder();
56+
57+
assertThatThrownBy(() -> b.maxDecompressedRequestBytes(0))
58+
.isInstanceOf(IllegalArgumentException.class)
59+
.hasMessageContaining("0");
60+
assertThatThrownBy(() -> b.maxDecompressedRequestBytes(-1))
61+
.isInstanceOf(IllegalArgumentException.class)
62+
.hasMessageContaining("-1");
63+
}
64+
65+
@Test
66+
void rejectsOversizedMaxDecompressedRequestBytes() {
67+
OpenApiServer.Builder b = OpenApiServer.builder();
68+
69+
assertThatThrownBy(() -> b.maxDecompressedRequestBytes(Integer.MAX_VALUE + 1L))
70+
.isInstanceOf(IllegalArgumentException.class);
71+
}
72+
73+
@Test
74+
void rejectsNegativeMinimumGzipResponseBytes() {
75+
OpenApiServer.Builder b = OpenApiServer.builder();
76+
77+
assertThatThrownBy(() -> b.minimumGzipResponseBytes(-1))
78+
.isInstanceOf(IllegalArgumentException.class)
79+
.hasMessageContaining("-1");
80+
}
81+
82+
@Test
83+
void acceptsContentCodingLimits() {
84+
OpenApiServer.Builder b = OpenApiServer.builder();
85+
86+
assertThat(b.maxDecompressedRequestBytes(4096).minimumGzipResponseBytes(0)).isSameAs(b);
87+
}
88+
5289
@Test
5390
void rejectsNegativeShutdownTimeout() {
5491
OpenApiServer.Builder b = OpenApiServer.builder();

0 commit comments

Comments
 (0)