Skip to content

Commit fa94f5c

Browse files
committed
fix(client): keep reproducible body across header rewrites; harden docs+tests
Addresses code-review findings on the reproducible-request-bodies work: - ReproducibleHttpRequest now overrides withHeaders() to rebind the same body factory to the new headers. Previously a header rewrite (e.g. a base-URI path prefix applied by DefaultWebClient, or a decorator overriding the path) wrapped the request in HeaderOverridingHttpRequest, which does not override toDuplicator and fell back to the buffering DefaultStreamMessageDuplicator -- silently reintroducing the ~2 GiB ContentTooLargeException this request type exists to avoid. - Corrects the HttpRequest.reproducible javadoc: RedirectingClient is not always the outermost duplicating decorator (redirects are disabled by default), so with retries only, RetryingClient is outermost and replays reproducibly. Also documents that each factory invocation must produce an equivalent body (not merely the same length), since the fixed content-length is reused unvalidated, and documents throwing/null factory fail-fast semantics. - Tests: add direct-consume coverage (normal / throwing / null factory via a plain WebClient with no decorator), a base-path-prefix regression test that fails without the withHeaders fix, and non-buffering proofs (toDuplicator ignores maxRequestLength; large body streams without accumulation). Tighten deterministic factory-invocation assertions from >= 2 to exactly 2 so an over-regeneration bug is caught rather than masked. Co-authored-by: Isaac
1 parent 8860934 commit fa94f5c

4 files changed

Lines changed: 217 additions & 21 deletions

File tree

core/src/main/java/com/linecorp/armeria/common/HttpRequest.java

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -383,23 +383,39 @@ static HttpRequest of(RequestHeaders headers,
383383
* <p>The {@code bodyFactory} is invoked once per attempt (initial request, retry attempt, or
384384
* redirect hop). The fixed {@code headers} are reused for every attempt; the factory regenerates
385385
* only the body {@link StreamMessage}, so the request method and headers cannot drift between
386-
* attempts. The body each factory invocation produces must match the declared
387-
* {@link HttpHeaderNames#CONTENT_LENGTH} (if any), exactly as for any streaming
388-
* {@link HttpRequest}.
389-
*
390-
* <p>Reproducible replay applies only at the outermost duplicating decorator, which for a
391-
* {@code WebClient} is {@code RedirectingClient} (it wraps the user-supplied decorators, including
392-
* {@code RetryingClient}). Each attempt that the outermost decorator hands downstream is an
393-
* ordinary {@link HttpRequest}, so an inner decorator treats it as a normal request and may buffer
394-
* it: with both redirect and retry enabled, {@code RedirectingClient} replays reproducibly across
395-
* redirect hops, but an inner {@code RetryingClient} re-buffers the body when retrying within a
396-
* single hop. Reproducibility is honored for streaming requests
386+
* attempts. Every invocation must produce an <em>equivalent</em> body — the same bytes and
387+
* trailers, not merely the same length — because the fixed {@code headers} (including any declared
388+
* {@link HttpHeaderNames#CONTENT_LENGTH}) are reused verbatim on every attempt and are not
389+
* re-validated against the regenerated body. A factory whose output varies across invocations
390+
* (e.g. it embeds a timestamp, or reads a file being mutated concurrently) silently sends
391+
* different data on a retry or redirect, and a length mismatch against a declared
392+
* {@code content-length} corrupts wire framing (the request stalls or the next message is
393+
* garbled) with no error surfaced.
394+
*
395+
* <p>Reproducible replay applies only at the <em>outermost</em> duplicating decorator; each attempt
396+
* it hands downstream is an ordinary {@link HttpRequest}, so any inner duplicating decorator treats
397+
* it as a normal request and buffers it. Which decorator is outermost depends on configuration:
398+
* <ul>
399+
* <li>With retries only (the common case; redirects are disabled by default),
400+
* {@code RetryingClient} is the outermost decorator and replays reproducibly across every
401+
* retry.</li>
402+
* <li>With {@code followRedirects()} enabled, the built-in {@code RedirectingClient} wraps the
403+
* user-supplied decorators (including {@code RetryingClient}), so it replays reproducibly
404+
* across redirect hops, but an inner {@code RetryingClient} re-buffers the body when retrying
405+
* within a single hop — reintroducing the ~2 GiB limit for that retry. If you need
406+
* non-buffering retries of a very large body, avoid stacking {@code RetryingClient} beneath
407+
* {@code RedirectingClient}.</li>
408+
* </ul>
409+
* Reproducibility is honored for streaming requests
397410
* ({@link ExchangeType#isRequestStreaming()}); an aggregated exchange type buffers the body as
398411
* usual.
399412
*
400413
* @param headers the fixed {@link RequestHeaders} reused for every attempt
401-
* @param bodyFactory produces a fresh body {@link StreamMessage} for each attempt; it must not
402-
* return {@code null}
414+
* @param bodyFactory produces a fresh body {@link StreamMessage} for each attempt, each equivalent
415+
* to the first; it must not return {@code null}. If an invocation throws or
416+
* returns {@code null}, that attempt fails with the thrown cause (or a
417+
* {@link NullPointerException}) and the failure is propagated to the caller
418+
* without exhausting the remaining retry budget.
403419
*/
404420
@UnstableApi
405421
static HttpRequest reproducible(

core/src/main/java/com/linecorp/armeria/common/ReproducibleHttpRequest.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
package com.linecorp.armeria.common;
1818

19+
import static java.util.Objects.requireNonNull;
20+
1921
import java.util.concurrent.CompletableFuture;
2022
import java.util.function.Supplier;
2123

@@ -96,6 +98,21 @@ public RequestHeaders headers() {
9698
return headers;
9799
}
98100

101+
@Override
102+
public HttpRequest withHeaders(RequestHeaders newHeaders) {
103+
requireNonNull(newHeaders, "newHeaders");
104+
if (headers == newHeaders) {
105+
return this;
106+
}
107+
// Preserve reproducibility across a header rewrite (e.g. a base-URI path prefix applied by
108+
// DefaultWebClient, or a redirect/retry decorator overriding the path). The default
109+
// HttpRequest.withHeaders wraps this in a HeaderOverridingHttpRequest, which does not override
110+
// toDuplicator and would therefore fall back to the buffering DefaultHttpRequestDuplicator,
111+
// reintroducing the ~2 GiB size limit this request type exists to avoid. Rebinding the same
112+
// factory to the new headers keeps the non-buffering toDuplicator path.
113+
return new ReproducibleHttpRequest(newHeaders, bodyFactory);
114+
}
115+
99116
@SuppressWarnings("unchecked")
100117
@Override
101118
public CompletableFuture<AggregatedHttpRequest> aggregate(AggregationOptions options) {

core/src/test/java/com/linecorp/armeria/client/ReproducibleHttpRequestClientTest.java

Lines changed: 153 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,17 @@
2828

2929
import com.linecorp.armeria.client.retry.RetryRule;
3030
import com.linecorp.armeria.client.retry.RetryingClient;
31+
import com.linecorp.armeria.common.AggregatedHttpRequest;
3132
import com.linecorp.armeria.common.AggregatedHttpResponse;
33+
import com.linecorp.armeria.common.CommonPools;
3234
import com.linecorp.armeria.common.ExchangeType;
3335
import com.linecorp.armeria.common.HttpData;
3436
import com.linecorp.armeria.common.HttpHeaderNames;
3537
import com.linecorp.armeria.common.HttpHeaders;
3638
import com.linecorp.armeria.common.HttpMethod;
3739
import com.linecorp.armeria.common.HttpObject;
3840
import com.linecorp.armeria.common.HttpRequest;
41+
import com.linecorp.armeria.common.HttpRequestDuplicator;
3942
import com.linecorp.armeria.common.HttpResponse;
4043
import com.linecorp.armeria.common.HttpStatus;
4144
import com.linecorp.armeria.common.MediaType;
@@ -45,6 +48,8 @@
4548
import com.linecorp.armeria.server.ServerBuilder;
4649
import com.linecorp.armeria.testing.junit5.server.ServerExtension;
4750

51+
import io.netty.util.concurrent.EventExecutor;
52+
4853
class ReproducibleHttpRequestClientTest {
4954

5055
private static final AtomicInteger serverHits = new AtomicInteger();
@@ -93,6 +98,24 @@ protected void configure(ServerBuilder sb) {
9398
req.aggregate().thenApply(agg -> AggregatedHttpResponse.of(
9499
HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8,
95100
req.method() + ":" + agg.contentUtf8()).toHttpResponse())));
101+
// Plain echo that always succeeds, for exercising the request without any retry/redirect
102+
// decorator (the direct-consume path) and for a base-path-prefixed client.
103+
sb.service("/echo", (ctx, req) -> HttpResponse.of(
104+
req.aggregate().thenApply(agg -> AggregatedHttpResponse.of(
105+
HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8,
106+
agg.contentUtf8()).toHttpResponse())));
107+
// Same fail-once-then-echo behavior as /upload, reached only when a base-URI path prefix
108+
// ("/api") rewrites the request path — the scenario that must keep the reproducible body.
109+
sb.service("/api/upload", (ctx, req) -> HttpResponse.of(
110+
req.aggregate().thenApply(agg -> {
111+
final int hit = serverHits.incrementAndGet();
112+
if (hit == 1) {
113+
return AggregatedHttpResponse.of(HttpStatus.INTERNAL_SERVER_ERROR)
114+
.toHttpResponse();
115+
}
116+
return AggregatedHttpResponse.of(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8,
117+
agg.contentUtf8()).toHttpResponse();
118+
})));
96119
}
97120
};
98121

@@ -145,9 +168,12 @@ void retryRegeneratesBody() {
145168

146169
assertThat(res.status()).isEqualTo(HttpStatus.OK);
147170
assertThat(res.contentUtf8()).isEqualTo("hello-body");
148-
assertThat(serverHits).hasValueGreaterThanOrEqualTo(2);
171+
// The server fails exactly once, so the attempt count is deterministic: initial + one retry.
172+
// Assert exactly 2 so a double-subscription bug that regenerates the body 3+ times (leaking a
173+
// fresh factory resource per attempt) is caught rather than masked by a >= assertion.
174+
assertThat(serverHits).hasValue(2);
149175
// Body regenerated for the initial attempt and the retry.
150-
assertThat(bodyCalls).hasValueGreaterThanOrEqualTo(2);
176+
assertThat(bodyCalls).hasValue(2);
151177
}
152178

153179
@Test
@@ -179,8 +205,9 @@ void retryReproducesMultiChunkBodyAndTrailers() {
179205
assertThat(res.status()).isEqualTo(HttpStatus.OK);
180206
// Concatenated interior chunks (in order) plus the reproduced trailer, on the re-sent attempt.
181207
assertThat(res.contentUtf8()).isEqualTo("abc|v");
182-
assertThat(serverHits).hasValueGreaterThanOrEqualTo(2);
183-
assertThat(bodyCalls).hasValueGreaterThanOrEqualTo(2);
208+
// Deterministic: initial + one retry. Exact assertion guards against over-regeneration.
209+
assertThat(serverHits).hasValue(2);
210+
assertThat(bodyCalls).hasValue(2);
184211
}
185212

186213
@Test
@@ -238,8 +265,9 @@ void followsRedirectRegeneratingBody() {
238265

239266
assertThat(res.status()).isEqualTo(HttpStatus.OK);
240267
assertThat(res.contentUtf8()).isEqualTo("redir-body");
241-
// Body regenerated for the initial request and the redirected hop.
242-
assertThat(bodyCalls).hasValueGreaterThanOrEqualTo(2);
268+
// Deterministic: initial request + one redirect hop (no server error, so no retry). Exact
269+
// assertion guards against over-regeneration.
270+
assertThat(bodyCalls).hasValue(2);
243271
}
244272

245273
@Test
@@ -291,7 +319,124 @@ void stackedRetryAndRedirect() {
291319

292320
assertThat(res.status()).isEqualTo(HttpStatus.OK);
293321
assertThat(res.contentUtf8()).isEqualTo("redir-body");
294-
// Body regenerated for the initial request and the redirected hop.
295-
assertThat(bodyCalls).hasValueGreaterThanOrEqualTo(2);
322+
// Deterministic: initial request + one redirect hop (no server error, so no retry). Exact
323+
// assertion guards against over-regeneration.
324+
assertThat(bodyCalls).hasValue(2);
325+
}
326+
327+
@Test
328+
void directConsumeWithoutDecoratorSendsBodyOnce() {
329+
// No retry/redirect decorator, so the request is consumed directly via its lazyBody delegate
330+
// (never through toDuplicator). This path is otherwise unexercised — every other test drives a
331+
// decorator. The factory must be invoked exactly once and the body delivered intact.
332+
final AtomicInteger bodyCalls = new AtomicInteger();
333+
final RequestHeaders headers =
334+
RequestHeaders.of(HttpMethod.POST, "/echo",
335+
HttpHeaderNames.CONTENT_TYPE, MediaType.PLAIN_TEXT_UTF_8);
336+
final Supplier<StreamMessage<? extends HttpObject>> bodyFactory = () -> {
337+
bodyCalls.incrementAndGet();
338+
return StreamMessage.of(HttpData.ofUtf8("direct-body"));
339+
};
340+
341+
final WebClient client = WebClient.of(server.httpUri());
342+
final AggregatedHttpResponse res =
343+
client.execute(HttpRequest.reproducible(headers, bodyFactory), streamingOptions())
344+
.aggregate().join();
345+
346+
assertThat(res.status()).isEqualTo(HttpStatus.OK);
347+
assertThat(res.contentUtf8()).isEqualTo("direct-body");
348+
assertThat(bodyCalls).hasValue(1);
349+
}
350+
351+
@Test
352+
void directConsumeSurfacesThrowingFactory() {
353+
// On the direct path, a throwing factory must surface its own cause to the subscriber rather
354+
// than hang or swallow the error.
355+
final RequestHeaders headers =
356+
RequestHeaders.of(HttpMethod.POST, "/echo",
357+
HttpHeaderNames.CONTENT_TYPE, MediaType.PLAIN_TEXT_UTF_8);
358+
final Supplier<StreamMessage<? extends HttpObject>> bodyFactory = () -> {
359+
throw new IllegalStateException("cannot produce body");
360+
};
361+
362+
final WebClient client = WebClient.of(server.httpUri());
363+
assertThatThrownBy(() -> client.execute(HttpRequest.reproducible(headers, bodyFactory),
364+
streamingOptions())
365+
.aggregate().join())
366+
.getRootCause()
367+
.isInstanceOf(IllegalStateException.class)
368+
.hasMessageContaining("cannot produce body");
369+
}
370+
371+
@Test
372+
void directConsumeSurfacesNullFactory() {
373+
// On the direct path, a factory returning null must surface an NPE, matching the duplicator
374+
// path's null handling.
375+
final RequestHeaders headers =
376+
RequestHeaders.of(HttpMethod.POST, "/echo",
377+
HttpHeaderNames.CONTENT_TYPE, MediaType.PLAIN_TEXT_UTF_8);
378+
final Supplier<StreamMessage<? extends HttpObject>> bodyFactory = () -> null;
379+
380+
final WebClient client = WebClient.of(server.httpUri());
381+
assertThatThrownBy(() -> client.execute(HttpRequest.reproducible(headers, bodyFactory),
382+
streamingOptions())
383+
.aggregate().join())
384+
.getRootCause()
385+
.isInstanceOf(NullPointerException.class);
386+
}
387+
388+
@Test
389+
void toDuplicatorIgnoresMaxRequestLength() {
390+
// The reproducible duplicator never buffers, so it must ignore the maxRequestLength cap that a
391+
// buffering DefaultStreamMessageDuplicator would enforce. A body far larger than a tiny cap must
392+
// still stream to completion; a regression that fell back to a buffering duplicator would throw
393+
// ContentTooLargeException here.
394+
final byte[] large = new byte[64 * 1024];
395+
final Supplier<StreamMessage<? extends HttpObject>> bodyFactory =
396+
() -> StreamMessage.of(HttpData.wrap(large));
397+
final RequestHeaders headers = RequestHeaders.of(HttpMethod.POST, "/echo");
398+
399+
final EventExecutor executor = CommonPools.workerGroup().next();
400+
final HttpRequestDuplicator duplicator =
401+
HttpRequest.reproducible(headers, bodyFactory).toDuplicator(executor, 8);
402+
403+
final AggregatedHttpRequest produced = duplicator.duplicate().aggregate().join();
404+
assertThat(produced.content().length()).isEqualTo(large.length);
405+
duplicator.close();
406+
}
407+
408+
@Test
409+
void basePathPrefixRemainsReproducible() {
410+
// A WebClient built with a base-URI path prefix rewrites the request path via
411+
// req.withHeaders(...). If ReproducibleHttpRequest did not override withHeaders, the rewritten
412+
// request would be a plain HeaderOverridingHttpRequest whose toDuplicator falls back to the
413+
// buffering DefaultStreamMessageDuplicator — silently reintroducing the ~2 GiB limit. This test
414+
// pins that the rewritten request still regenerates its body per attempt (non-buffering path).
415+
final AtomicInteger bodyCalls = new AtomicInteger();
416+
// Header path is "/upload"; the base URI prefix "/api" makes the effective path "/api/upload",
417+
// forcing a path rewrite. Route the server so /api/upload retries once like /upload does.
418+
final RequestHeaders headers =
419+
RequestHeaders.of(HttpMethod.POST, "/upload",
420+
HttpHeaderNames.CONTENT_TYPE, MediaType.PLAIN_TEXT_UTF_8);
421+
final Supplier<StreamMessage<? extends HttpObject>> bodyFactory = () -> {
422+
bodyCalls.incrementAndGet();
423+
return StreamMessage.of(HttpData.ofUtf8("prefixed-body"));
424+
};
425+
426+
final WebClient client =
427+
WebClient.builder(server.httpUri() + "/api")
428+
.decorator(RetryingClient.newDecorator(
429+
RetryRule.builder().onServerErrorStatus().thenBackoff()))
430+
.build();
431+
432+
final AggregatedHttpResponse res =
433+
client.execute(HttpRequest.reproducible(headers, bodyFactory), streamingOptions())
434+
.aggregate().join();
435+
436+
assertThat(res.status()).isEqualTo(HttpStatus.OK);
437+
assertThat(res.contentUtf8()).isEqualTo("prefixed-body");
438+
// Regenerated for the initial attempt and the retry — proving the path-rewritten request kept
439+
// the reproducible (non-buffering) duplicator rather than falling back to buffering.
440+
assertThat(bodyCalls).hasValue(2);
296441
}
297442
}

core/src/test/java/com/linecorp/armeria/internal/common/ReproducibleHttpRequestDuplicatorTest.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,24 @@ void closeLeavesOutstandingRequestsActive() {
132132
assertThat(produced.whenComplete()).isNotDone();
133133
}
134134

135+
@Test
136+
void streamsBodyWithoutAccumulatingIt() {
137+
// The whole point of the reproducible duplicator is to avoid buffering the body for replay (and
138+
// thus the ~2 GiB int32 cap of DefaultStreamMessageDuplicator). A produced request must stream
139+
// its body straight through rather than accumulate it; here we simply assert a large body is
140+
// delivered intact. See ReproducibleHttpRequestClientTest#toDuplicatorIgnoresMaxRequestLength
141+
// for the companion proof that the maxRequestLength cap is not applied.
142+
final byte[] large = new byte[64 * 1024];
143+
final Supplier<StreamMessage<? extends HttpObject>> factory =
144+
() -> StreamMessage.of(HttpData.wrap(large));
145+
final ReproducibleHttpRequestDuplicator dup =
146+
new ReproducibleHttpRequestDuplicator(HEADERS, factory);
147+
148+
final HttpRequest produced = dup.duplicate();
149+
final int received = produced.aggregate().join().content().length();
150+
assertThat(received).isEqualTo(large.length);
151+
}
152+
135153
@Test
136154
void completedRequestIsUntrackedSoAbortDoesNotAffectIt() {
137155
final Supplier<StreamMessage<? extends HttpObject>> factory =

0 commit comments

Comments
 (0)