Skip to content

Reproducible request bodies - #6841

Open
yzfeng2020 wants to merge 19 commits into
line:mainfrom
yzfeng2020:feature/reproducible-request-bodies
Open

Reproducible request bodies#6841
yzfeng2020 wants to merge 19 commits into
line:mainfrom
yzfeng2020:feature/reproducible-request-bodies

Conversation

@yzfeng2020

@yzfeng2020 yzfeng2020 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Motivation:

When a streaming request body larger than ~2 GiB is sent through RetryingClient or
RedirectingClient, the request fails with ContentTooLargeException even though the
application configured no content-length limit.

Both clients must be able to replay the request body across retry attempts / redirect hops, so
for streaming requests they wrap it in a duplicator:

final HttpRequestDuplicator reqDuplicator = req.toDuplicator(ctx.eventLoop().withoutContext(), 0);

The 0 argument means "no limit", but DefaultStreamMessageDuplicator maps 0 (and any value
> Integer.MAX_VALUE) to Integer.MAX_VALUE and then buffers the entire body in memory,
accumulating the total into an int signalLength field. Once the accumulated body crosses
Integer.MAX_VALUE (~2 GiB), the duplicator throws ContentTooLargeException and aborts the
request.

There are two coupled problems:

  1. The int32 cap — the running total is an int, so replay-buffering is capped at ~2 GiB
    even when the caller explicitly asked for no limit (0).
  2. Buffering at all — even below 2 GiB, the whole upload is held on the heap solely to enable
    replay, which is undesirable for large uploads.

Modifications:

  • Add HttpRequest.reproducible(RequestHeaders, Supplier<StreamMessage<? extends HttpObject>>),
    a first-class request type (@UnstableApi). The caller supplies fixed headers and a factory
    that opens a fresh body StreamMessage on demand, declaring the body reproducible instead
    of relying on the client to buffer it for replay.
  • Add ReproducibleHttpRequestDuplicator, a non-buffering HttpRequestDuplicator. Every
    duplicate() — including the first — obtains a fresh body from the factory, so no attempt
    reuses another attempt's stream and the caller's request is never itself put on the wire. This
    avoids both the ~2 GiB int cap and the memory cost of DefaultStreamMessageDuplicator.
  • ReproducibleHttpRequest overrides toDuplicator(...) to return that non-buffering duplicator,
    so RetryingClient and RedirectingClient keep their single unconditional
    req.toDuplicator(...) call — no attribute read, no special-case selection block. Other
    callers of toDuplicator (circuit-breaker / rule utilities) benefit automatically.

Design notes:

  • The behavior is unchanged (byte-for-byte) when a plain HttpRequest is supplied; only a
    request built via HttpRequest.reproducible(...) takes the non-buffering path.
  • The body factory is invoked lazily, never eagerly at construction: once per attempt on the
    retry/redirect path, or exactly once on direct subscription when no duplicating decorator is
    present.
  • Every attempt (including the first) is regenerated from the factory. Because the caller's
    request is never the live wire stream, a mid-body transport failure on the first attempt no
    longer completes its whenComplete() exceptionally and short-circuits all retries — the prior
    attribute-based design's retry regression.
  • Fixed headers are reused for every attempt, so the request method and declared
    content-length cannot drift between attempts. The body each factory invocation produces must
    match the declared content-length, exactly as for any streaming HttpRequest.
  • duplicate() fails fast: a factory that throws or returns null propagates to the client,
    which completes the response exceptionally instead of re-judging an aborted request and burning
    the retry budget.
  • The duplicator tracks the single last-produced request (volatile, for the caller-thread →
    event-loop hand-off) so abort() releases a produced-but-unsubscribed body (e.g. endpoint
    selection threw before the wire subscribed); close() lets an in-flight subscribed request
    finish, per the StreamMessageDuplicator contract.
  • Reproducibility applies at the outermost duplicating decorator only. Each attempt handed
    downstream is an ordinary HttpRequest; an inner hop (e.g. a redirect within a single retry
    attempt) treats it as a normal request and may buffer it.
  • Applies to streaming requests only (ExchangeType#isRequestStreaming()); an aggregated
    exchange type buffers the body as usual.
  • 303 See Other (method rewritten to GET, body dropped) simply aborts the duplicator and never
    calls the factory again.

Result:

…ssued volatile

Follow-ups from the whole-branch review:
- Mark RequestFactoryHttpRequestDuplicator.firstDuplicateIssued volatile for safe
  publication across the caller-thread -> event-loop hand-off (accesses are ordered
  via the async response future, but not confined to one thread).
- Rename the factory-duplicator size test to reflect what it actually asserts.
- Add end-to-end tests: factory throwing on a retry fails the request; a non-streaming
  request ignores the factory; a 303 See Other redirect drops the body (method -> GET)
  without invoking the factory again.
- Reset the server-hit counter via @beforeeach.

Co-authored-by: Isaac
@yzfeng2020
yzfeng2020 marked this pull request as draft July 6, 2026 18:08
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds HttpRequest.reproducible(headers, bodyFactory) and a lazy request wrapper that regenerates streaming bodies per attempt. Retry and redirect clients now catch duplication failures inside existing exception handling, with tests covering retry, redirect, and duplicator lifecycle behavior.

Changes

Reproducible body regeneration for streaming requests

Layer / File(s) Summary
Reproducible request body API
core/src/main/java/com/linecorp/armeria/common/HttpRequest.java, core/src/main/java/com/linecorp/armeria/common/ReproducibleHttpRequest.java
Adds the reproducible request factory and lazily invokes the supplied body factory when the request body is subscribed.
Fresh-body duplicator lifecycle
core/src/main/java/com/linecorp/armeria/internal/common/ReproducibleHttpRequestDuplicator.java, core/src/test/java/com/linecorp/armeria/internal/common/ReproducibleHttpRequestDuplicatorTest.java
Creates fresh request bodies per duplication, supports header overrides, tracks active requests, and tests factory failures, close, abort, and completion behavior.
Retry and redirect duplication handling
core/src/main/java/com/linecorp/armeria/client/RedirectingClient.java, core/src/main/java/com/linecorp/armeria/client/retry/RetryingClient.java
Moves request duplication into existing try blocks so duplication failures are routed through handleException(...).
Retry and redirect integration validation
core/src/test/java/com/linecorp/armeria/client/ReproducibleHttpRequestClientTest.java, core/src/test/java/com/linecorp/armeria/client/retry/ReproducibleHttpRequestRetryTest.java
Tests lazy creation, body regeneration across retries and redirects, 303 body removal, combined retry and redirect behavior, and mid-stream retry recovery.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant RetryingClient
  participant RedirectingClient
  participant ReproducibleHttpRequestDuplicator
  participant BodyFactory
  participant Server

  Client->>RetryingClient: execute streaming request
  RetryingClient->>ReproducibleHttpRequestDuplicator: duplicate()
  ReproducibleHttpRequestDuplicator->>BodyFactory: create fresh StreamMessage
  BodyFactory-->>ReproducibleHttpRequestDuplicator: new body
  RetryingClient->>RedirectingClient: execute request
  RedirectingClient->>ReproducibleHttpRequestDuplicator: duplicate()
  ReproducibleHttpRequestDuplicator->>BodyFactory: create fresh StreamMessage
  RedirectingClient->>Server: send retry or redirect attempt
Loading

Suggested reviewers: ikhoon, minwoox

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Concise title matches the main change: adding reproducible request bodies.
Description check ✅ Passed The description directly discusses the reproducible-body fix and its motivation.
Linked Issues check ✅ Passed The change addresses #6835 by adding reproducible request bodies and non-buffering duplication for retries and redirects.
Out of Scope Changes check ✅ Passed The code and tests stay focused on reproducible request bodies and related retry/redirect behavior.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
core/src/test/java/com/linecorp/armeria/internal/client/RequestFactoryHttpRequestDuplicatorTest.java (1)

110-126: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Test doesn't actually exercise "no size cap" — only asserts non-null over 6 tiny requests.

The comment claims to verify behavior across "large-'reported'-length duplications," but the test body never uses large content; it just checks dup.duplicate() returns non-null a handful of times. Consider renaming/trimming the comment to reflect what's actually tested (repeated duplication succeeds), or add a request with a genuinely large/streaming body if the size-cap-avoidance claim is meant to be verified here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/src/test/java/com/linecorp/armeria/internal/client/RequestFactoryHttpRequestDuplicatorTest.java`
around lines 110 - 126, The test in
RequestFactoryHttpRequestDuplicatorTest#factoryDuplicatorHasNoSizeCapAcrossManyDuplicates
currently claims to verify no size cap for large reported lengths, but it only
checks a few non-null duplicates of tiny requests. Either update the
test/comment to match the actual behavior being exercised in
RequestFactoryHttpRequestDuplicator.duplicate() (repeated duplication succeeds),
or change the setup to use a genuinely large or streaming HttpRequest body if
you intend to prove the size-cap path is bypassed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@core/src/test/java/com/linecorp/armeria/client/RedirectingClientReproducibleBodyTest.java`:
- Around line 95-124: The 303 redirect test in
RedirectingClientReproducibleBodyTest verifies the response but never checks
that factoryCalls stays at the expected count. Update
seeOtherRedirectDropsBodyAndDoesNotThrow() to assert the factory is only used
for the initial request and not invoked again for the SEE_OTHER hop, using the
existing factoryCalls AtomicInteger so the redirect body-drop behavior is
actually enforced.

---

Nitpick comments:
In
`@core/src/test/java/com/linecorp/armeria/internal/client/RequestFactoryHttpRequestDuplicatorTest.java`:
- Around line 110-126: The test in
RequestFactoryHttpRequestDuplicatorTest#factoryDuplicatorHasNoSizeCapAcrossManyDuplicates
currently claims to verify no size cap for large reported lengths, but it only
checks a few non-null duplicates of tiny requests. Either update the
test/comment to match the actual behavior being exercised in
RequestFactoryHttpRequestDuplicator.duplicate() (repeated duplication succeeds),
or change the setup to use a genuinely large or streaming HttpRequest body if
you intend to prove the size-cap path is bypassed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 716d108e-71a8-4c6c-92a0-ad132a0b79b9

📥 Commits

Reviewing files that changed from the base of the PR and between 051d14e and 4ca6f45.

📒 Files selected for processing (8)
  • core/src/main/java/com/linecorp/armeria/client/ClientRequestBodyFactory.java
  • core/src/main/java/com/linecorp/armeria/client/RedirectingClient.java
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryingClient.java
  • core/src/main/java/com/linecorp/armeria/internal/client/RequestFactoryHttpRequestDuplicator.java
  • core/src/test/java/com/linecorp/armeria/client/ClientRequestBodyFactoryTest.java
  • core/src/test/java/com/linecorp/armeria/client/RedirectingClientReproducibleBodyTest.java
  • core/src/test/java/com/linecorp/armeria/client/retry/RetryingClientReproducibleBodyTest.java
  • core/src/test/java/com/linecorp/armeria/internal/client/RequestFactoryHttpRequestDuplicatorTest.java

Comment on lines +95 to +124
@Test
void seeOtherRedirectDropsBodyAndDoesNotThrow() {
final AtomicInteger factoryCalls = new AtomicInteger();
final Supplier<HttpRequest> factory = () -> {
factoryCalls.incrementAndGet();
return HttpRequest.of(RequestHeaders.of(HttpMethod.POST, "/see-other",
HttpHeaderNames.CONTENT_TYPE,
"text/plain; charset=utf-8"),
StreamMessage.of(HttpData.ofUtf8("see-other-body")));
};

final WebClient client =
WebClient.builder(server.httpUri())
.followRedirects()
.build();

final RequestOptions options =
RequestOptions.builder()
.exchangeType(ExchangeType.REQUEST_STREAMING)
.attr(ClientRequestBodyFactory.REQUEST_BODY_FACTORY, factory)
.build();

final AggregatedHttpResponse res =
client.execute(factory.get(), options).aggregate().join();

// On a 303 the method is rewritten to GET and the body is dropped; the redirected GET has
// an empty body. The factory duplicator is aborted and never invoked again for the hop.
assertThat(res.status()).isEqualTo(HttpStatus.OK);
assertThat(res.contentUtf8()).isEqualTo("GET:");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Missing assertion on factoryCalls leaves the 303 behavior unverified.

The test declares and increments factoryCalls but never asserts on it, so the claim in the comment ("aborted and never invoked again for the hop") is not actually verified. A regression that invokes the factory on the SEE_OTHER hop would pass silently.

🧪 Proposed fix
         assertThat(res.status()).isEqualTo(HttpStatus.OK);
         assertThat(res.contentUtf8()).isEqualTo("GET:");
+        // The factory is invoked once for the initial request; the SEE_OTHER hop drops the body
+        // via reqDuplicator.abort() instead of calling the factory again.
+        assertThat(factoryCalls).hasValue(1);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Test
void seeOtherRedirectDropsBodyAndDoesNotThrow() {
final AtomicInteger factoryCalls = new AtomicInteger();
final Supplier<HttpRequest> factory = () -> {
factoryCalls.incrementAndGet();
return HttpRequest.of(RequestHeaders.of(HttpMethod.POST, "/see-other",
HttpHeaderNames.CONTENT_TYPE,
"text/plain; charset=utf-8"),
StreamMessage.of(HttpData.ofUtf8("see-other-body")));
};
final WebClient client =
WebClient.builder(server.httpUri())
.followRedirects()
.build();
final RequestOptions options =
RequestOptions.builder()
.exchangeType(ExchangeType.REQUEST_STREAMING)
.attr(ClientRequestBodyFactory.REQUEST_BODY_FACTORY, factory)
.build();
final AggregatedHttpResponse res =
client.execute(factory.get(), options).aggregate().join();
// On a 303 the method is rewritten to GET and the body is dropped; the redirected GET has
// an empty body. The factory duplicator is aborted and never invoked again for the hop.
assertThat(res.status()).isEqualTo(HttpStatus.OK);
assertThat(res.contentUtf8()).isEqualTo("GET:");
}
`@Test`
void seeOtherRedirectDropsBodyAndDoesNotThrow() {
final AtomicInteger factoryCalls = new AtomicInteger();
final Supplier<HttpRequest> factory = () -> {
factoryCalls.incrementAndGet();
return HttpRequest.of(RequestHeaders.of(HttpMethod.POST, "/see-other",
HttpHeaderNames.CONTENT_TYPE,
"text/plain; charset=utf-8"),
StreamMessage.of(HttpData.ofUtf8("see-other-body")));
};
final WebClient client =
WebClient.builder(server.httpUri())
.followRedirects()
.build();
final RequestOptions options =
RequestOptions.builder()
.exchangeType(ExchangeType.REQUEST_STREAMING)
.attr(ClientRequestBodyFactory.REQUEST_BODY_FACTORY, factory)
.build();
final AggregatedHttpResponse res =
client.execute(factory.get(), options).aggregate().join();
// On a 303 the method is rewritten to GET and the body is dropped; the redirected GET has
// an empty body. The factory duplicator is aborted and never invoked again for the hop.
assertThat(res.status()).isEqualTo(HttpStatus.OK);
assertThat(res.contentUtf8()).isEqualTo("GET:");
// The factory is invoked once for the initial request; the SEE_OTHER hop drops the body
// via reqDuplicator.abort() instead of calling the factory again.
assertThat(factoryCalls).hasValue(1);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/src/test/java/com/linecorp/armeria/client/RedirectingClientReproducibleBodyTest.java`
around lines 95 - 124, The 303 redirect test in
RedirectingClientReproducibleBodyTest verifies the response but never checks
that factoryCalls stays at the expected count. Update
seeOtherRedirectDropsBodyAndDoesNotThrow() to assert the factory is only used
for the initial request and not invoked again for the SEE_OTHER hop, using the
existing factoryCalls AtomicInteger so the redirect body-drop behavior is
actually enforced.

@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.12195% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.19%. Comparing base (8150425) to head (7440a5e).
⚠️ Report is 584 commits behind head on main.

Files with missing lines Patch % Lines
...necorp/armeria/common/ReproducibleHttpRequest.java 88.00% 2 Missing and 1 partial ⚠️
...eria/common/ReproducibleHttpRequestDuplicator.java 97.91% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #6841      +/-   ##
============================================
+ Coverage     74.46%   75.19%   +0.73%     
- Complexity    22234    25536    +3302     
============================================
  Files          1963     2269     +306     
  Lines         82437    94636   +12199     
  Branches      10764    12382    +1618     
============================================
+ Hits          61385    71164    +9779     
- Misses        15918    17600    +1682     
- Partials       5134     5872     +738     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…ss request type

Replace the attribute-based `ClientRequestBodyFactory.REQUEST_BODY_FACTORY`
(a `Supplier<HttpRequest>` read out of band by RetryingClient and
RedirectingClient) with a first-class `HttpRequest.reproducible(RequestHeaders,
Supplier<StreamMessage<HttpObject>>)`. The returned `ReproducibleHttpRequest`
overrides `toDuplicator()` to return a non-buffering
`ReproducibleHttpRequestDuplicator`, so both clients drop their special-case
selection block and call `req.toDuplicator(...)` unconditionally.

Every attempt — including the first — is regenerated from the factory, so the
caller's request is never itself put on the wire. This fixes the prior design's
retry-after-mid-body-failure regression: because the first attempt used the
live request, a mid-body transport failure completed its `whenComplete()`
exceptionally and RetryingClient short-circuited all retries.

The body factory is invoked lazily (never eagerly at construction): once per
attempt on the retry/redirect path, or once on direct subscription when no
duplicating decorator is present. Fixed headers are reused for every attempt so
the request method and content-length cannot drift. The duplicator tracks the
single last-produced request (volatile, for the caller-thread to event-loop
hand-off) so abort() can release a produced-but-unsubscribed body; close() lets
an in-flight subscribed request finish per the StreamMessageDuplicator contract.
duplicate() fails fast (propagates) when the factory throws or returns null, so
an unreproducible body terminates the request instead of burning the retry
budget. Reproducibility applies at the outermost duplicating decorator only;
inner hops see ordinary requests.

Copyright headers on new files use "LY Corporation".

Co-authored-by: Isaac

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
core/src/test/java/com/linecorp/armeria/client/ReproducibleHttpRequestClientTest.java (1)

158-161: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Tighten the exception assertion in factoryThrowingOnRetryFailsFast.

isInstanceOf(Exception.class) matches almost any throwable and could mask an unrelated failure. Consider asserting the wrapped cause to verify the factory's IllegalStateException propagates correctly.

♻️ Suggested refinement
-        assertThatThrownBy(() -> client.execute(HttpRequest.reproducible(headers, bodyFactory),
-                                                streamingOptions())
-                                       .aggregate().join())
-                .isInstanceOf(Exception.class);
+        assertThatThrownBy(() -> client.execute(HttpRequest.reproducible(headers, bodyFactory),
+                                                streamingOptions())
+                                       .aggregate().join())
+                .isInstanceOf(CompletionException.class)
+                .hasCauseInstanceOf(IllegalStateException.class)
+                .hasRootCauseMessage("cannot regenerate body");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/src/test/java/com/linecorp/armeria/client/ReproducibleHttpRequestClientTest.java`
around lines 158 - 161, The assertion in factoryThrowingOnRetryFailsFast is too
broad and can hide unrelated failures. Update the thrown-exception check around
client.execute(...).aggregate().join() to verify the wrapped cause from the
retry factory path, using the existing ReproducibleHttpRequestClientTest and
factoryThrowingOnRetryFailsFast identifiers, so it specifically confirms the
IllegalStateException is propagated instead of matching any Exception.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/src/main/java/com/linecorp/armeria/common/HttpRequest.java`:
- Line 374: The Javadoc example in HttpRequest uses an undefined variable name,
so update the snippet referenced by HttpRequest.reproducible to use a declared
value or a clearer placeholder instead of path. Make the example self-contained
and compilable by ensuring the argument passed to StreamMessage.of comes from a
properly defined local variable in the documentation sample.

In
`@core/src/test/java/com/linecorp/armeria/client/ReproducibleHttpRequestClientTest.java`:
- Around line 218-242: The stacked retry/redirect test in
ReproducibleHttpRequestClientTest leaves bodyCalls unused, so the body
regeneration count is not verified. Update stackedRetryAndRedirect to assert the
expected number of bodyFactory invocations via bodyCalls, or remove the
AtomicInteger tracking entirely if it is not needed. Use the existing
bodyFactory, bodyCalls, and client.execute(HttpRequest.reproducible(...)) flow
to locate the test and keep the redirect/retry coverage aligned with the
intended behavior.

---

Nitpick comments:
In
`@core/src/test/java/com/linecorp/armeria/client/ReproducibleHttpRequestClientTest.java`:
- Around line 158-161: The assertion in factoryThrowingOnRetryFailsFast is too
broad and can hide unrelated failures. Update the thrown-exception check around
client.execute(...).aggregate().join() to verify the wrapped cause from the
retry factory path, using the existing ReproducibleHttpRequestClientTest and
factoryThrowingOnRetryFailsFast identifiers, so it specifically confirms the
IllegalStateException is propagated instead of matching any Exception.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 03e8158e-56dd-4cea-99d7-11cd5a0014c4

📥 Commits

Reviewing files that changed from the base of the PR and between 4ca6f45 and c3928a1.

📒 Files selected for processing (8)
  • core/src/main/java/com/linecorp/armeria/client/RedirectingClient.java
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryingClient.java
  • core/src/main/java/com/linecorp/armeria/common/HttpRequest.java
  • core/src/main/java/com/linecorp/armeria/common/ReproducibleHttpRequest.java
  • core/src/main/java/com/linecorp/armeria/internal/common/ReproducibleHttpRequestDuplicator.java
  • core/src/test/java/com/linecorp/armeria/client/ReproducibleHttpRequestClientTest.java
  • core/src/test/java/com/linecorp/armeria/client/retry/ReproducibleHttpRequestRetryTest.java
  • core/src/test/java/com/linecorp/armeria/internal/common/ReproducibleHttpRequestDuplicatorTest.java
✅ Files skipped from review due to trivial changes (1)
  • core/src/main/java/com/linecorp/armeria/common/ReproducibleHttpRequest.java

Comment thread core/src/main/java/com/linecorp/armeria/common/HttpRequest.java
…ucibleHttpRequest

The single-arg toDuplicator now delegates to the two-arg sink; both
returned an identical duplicator. Merges the explanatory comments into
one. No behavioral change.

Co-authored-by: Isaac
- HttpRequest.reproducible javadoc: declare the `path` local used in the
  example so the snippet is self-contained and compilable.
- ReproducibleHttpRequestClientTest.stackedRetryAndRedirect: assert
  bodyCalls so the body-regeneration count through the retry+redirect
  chain is actually verified.

Co-authored-by: Isaac
@yzfeng2020
yzfeng2020 marked this pull request as ready for review July 9, 2026 19:57
@mergify

mergify Bot commented Jul 9, 2026

Copy link
Copy Markdown

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@jrhee17 jrhee17 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good overall, left some thoughts I had while looking through the PR

// A produced-but-unsubscribed request from a previous attempt (e.g. endpoint selection threw
// before the wire subscribed) would otherwise be lost when we overwrite lastProduced; abort it
// so its body is released. Aborting an already-subscribed stream is a no-op.
final HttpRequest previous = lastProduced;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I prefer that the semantics of duplicate match that of the existing DefaultStreamMessageDuplicator in that duplicated streams are left to be active as long as the originating StreamMessage is not closed. (So a collection of child streams would be maintained, and closing the upstream will also close all child streams)

This would also help with supporting hedging more easily in the future.

Alternatively, I'm fine with keeping the current implementation and improving in the future as well as I do think complexity of the impl would go up slightly.

Let me know of your thoughts as well @minwoox @ikhoon

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

duplicated streams are left to be active as long as the originating StreamMessage is not closed.

Yeah, I think it's the contract of a duplicated request.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and closing the upstream will also close all child streams

If a factory creates a new child stream, it creates an independent stream, so each child may publish different elements. I don't think it will be used that way in most cases.

That said, given the limitation of the current approach - that the upstream and downstream data may differ - I'm not sure it makes sense to close the downstream when the upstream is closed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a factory creates a new child stream, it creates an independent stream, so each child may publish different elements.

The contract guarantees that it's the same stream:

* Every invocation must produce an <em>equivalent</em> body — the same bytes and
* trailers, not merely the same length — because the fixed {@code headers} (including any declared
* {@link HttpHeaderNames#CONTENT_LENGTH}) are reused verbatim on every attempt and are not

Unlike DefaultStreamMessageDuplicator, there is no single upstream. So children never share buffered signals and — as you note — could in principle publish different elements. We treat that as a caller contract (each factory invocation must produce an equivalent body matching the fixed content-length) rather than enforcing it, since the intended use is re-opening the same source, e.g. a file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summarizing where this landed: there's no shared upstream by design — each duplicate() produces an independent, equivalent body from the factory, which is exactly what avoids the 2 GiB buffer. So "closing the upstream closes children" doesn't map; there's nothing shared to propagate. abort() tears down all outstanding children to release unsubscribed bodies (e.g. open files), and close() leaves in-flight children streaming per the StreamMessageDuplicator contract, matching DefaultHttpRequestDuplicator. Concurrent children are already supported, so hedging is accommodated without the shared-upstream model. Keeping the current semantics.

Comment on lines +37 to +38
* <p>{@code RetryingClient} and {@code RedirectingClient} drive this duplicator strictly
* sequentially: at most one produced request is outstanding at a time, and each access

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do think it is possible that abort on the original req can be called from any thread, which may result in a leak where the duplicated request isn't closed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is handled by the instance lock, and I've now added tests that pin it. The race — abort(cause) firing after the factory produced a body but before duplicate() registers it — is closed under the lock: if abort wins, duplicate observes closed, tears its just-produced body down with the remembered cause, and throws; if duplicate wins, the child is in children and abort's snapshot catches it. New coverage: duplicateAfterAbortTearsDownProducedBodyWithCause (sequential) and concurrentAbortDuringDuplicateTearsDownProducedBody (a barrier-gated, genuinely multi-threaded interleave). I mutation-verified the latter is non-vacuous: removing the synchronized guards makes it fail (leaked body, no throw).

* return {@code null}
*/
@UnstableApi
static HttpRequest reproducible(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I understood this is combining two concerns:

  • Adding a static HttpRequest of(RequestHeaders, Supplier<StreamMessage>) variant
  • Adding a HttpRequest#withDuplicatorBodyFactory

e.g.

static HttpRequest of(RequestHeaders headers,
                      Supplier<? extends StreamMessage<? extends HttpObject>> bodyFactory) {
    requireNonNull(headers, "headers");
    requireNonNull(bodyFactory, "bodyFactory");
    return of(headers, bodyFactory.get());
}

...

static HttpRequest reproducible(
        RequestHeaders headers,
        Supplier<? extends StreamMessage<? extends HttpObject>> bodyFactory) {
    requireNonNull(headers, "headers");
    requireNonNull(bodyFactory, "bodyFactory");
    return of(headers, bodyFactory).withDuplicatorFactory(bodyFactory);
}

For your use-case, do you see most users will prefer using reproducible method directly rather than separately? If so, I think it's fine to add a convenience method, but I just wanted to double-check.

I do think the single factory method has value in that users only need to specify a single Supplier.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — I'd prefer to keep the single reproducible(headers, factory) method rather than the two-method decomposition. The single-Supplier form makes the key invariant — the body source and the replay source are the same factory — unbreakable by construction. The decomposed shape (of(headers, Supplier) plus a separate withDuplicatorFactory(Supplier)) invites a footgun: two supplier references that can drift, or a forgotten withDuplicatorFactory silently falling back to the buffering (2 GiB-capped) path. Since this is @UnstableApi, we can add the composable primitives later if a concrete need arises, without committing to the wider surface now.

@jrhee17 jrhee17 added this to the 1.41.0 milestone Jul 13, 2026
Address maintainer review (jrhee17, minwoox) on PR line#6841:

- Match DefaultStreamMessageDuplicator semantics: every request produced by
  duplicate() stays active until it completes on its own, for as long as the
  duplicator is not aborted. Replace single last-produced tracking with a
  Set<HttpRequest> of children. close() prevents further duplication but leaves
  outstanding requests streaming; abort()/abort(cause) tears down every
  outstanding request. This also supports multiple concurrent outstanding
  requests (e.g. future hedging), not just the most recent one.

- Fix the abort-from-any-thread leak: abort() may run on the event loop while
  the caller thread is mid-duplicate(). Guard all state transitions with the
  instance lock so a concurrently produced request cannot be missed and leaked;
  a request produced after close/abort is aborted immediately and duplicate()
  then throws instead of returning an untearable request.

Co-authored-by: Isaac
…ten assert

Address review findings on reproducible request bodies:

- HttpRequest.reproducible Javadoc had the retry/redirect nesting backwards. In
  a WebClient, RedirectingClient wraps the user decorators (incl. RetryingClient),
  so RedirectingClient is the outermost duplicating decorator and replays
  reproducibly across redirect hops; an inner RetryingClient re-buffers when
  retrying within a single hop. Correct the direction.

- ReproducibleHttpRequest Javadoc claimed the factory is "never called eagerly /
  invoked exactly once on subscription". abort() before any real subscriber
  subscribes an aborting subscriber, which runs the factory once. Document that
  abort-before-subscribe counts as the first subscription (factory runs at most
  once on the direct path), and note the cold-path single-arg subscribe drops
  SubscriptionOptions / the caller executor.

- Add retryReproducesMultiChunkBodyAndTrailers: a genuinely multi-chunk body
  terminated by a trailer, asserting order and trailer survive reproduction on
  the re-sent attempt (previous tests only used a single chunk, no trailers).

- Tighten factoryThrowingOnRetryFailsFast to assert the surfaced root cause is
  the factory's IllegalStateException, not merely some Exception.

Co-authored-by: Isaac

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
core/src/test/java/com/linecorp/armeria/internal/common/ReproducibleHttpRequestDuplicatorTest.java (1)

104-151: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Consider a dedicated test for the duplicate()/abort() race window.

These tests cover sequential abort-after-produce, close-leaves-active, and completed-untracked cases well, but none exercises the actual concurrent race the class Javadoc (lines 49-54 in ReproducibleHttpRequestDuplicator.java) is designed to close: abort() firing from another thread while duplicate() is mid-execution, before the produced request is added to children. This is precisely the scenario jrhee17 flagged in a past review as a potential leak. A test using a latch to pause a thread inside duplicate() right before/after the synchronized block while a second thread calls abort() would give regression protection for this specific fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/src/test/java/com/linecorp/armeria/internal/common/ReproducibleHttpRequestDuplicatorTest.java`
around lines 104 - 151, Add a dedicated concurrency test for the
duplicate()/abort() race in ReproducibleHttpRequestDuplicator: use latches to
pause duplicate() around its synchronized child-registration boundary, invoke
abort() from another thread, then release duplication and assert the produced
request completes exceptionally. Ensure the test specifically verifies a request
created during the race cannot remain active or leak after abort().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@core/src/test/java/com/linecorp/armeria/internal/common/ReproducibleHttpRequestDuplicatorTest.java`:
- Around line 104-151: Add a dedicated concurrency test for the
duplicate()/abort() race in ReproducibleHttpRequestDuplicator: use latches to
pause duplicate() around its synchronized child-registration boundary, invoke
abort() from another thread, then release duplication and assert the produced
request completes exceptionally. Ensure the test specifically verifies a request
created during the race cannot remain active or leak after abort().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 62c775f5-6224-47eb-88b2-6d22f1c8a14c

📥 Commits

Reviewing files that changed from the base of the PR and between 705aaf5 and 80de365.

📒 Files selected for processing (5)
  • core/src/main/java/com/linecorp/armeria/common/HttpRequest.java
  • core/src/main/java/com/linecorp/armeria/common/ReproducibleHttpRequest.java
  • core/src/main/java/com/linecorp/armeria/internal/common/ReproducibleHttpRequestDuplicator.java
  • core/src/test/java/com/linecorp/armeria/client/ReproducibleHttpRequestClientTest.java
  • core/src/test/java/com/linecorp/armeria/internal/common/ReproducibleHttpRequestDuplicatorTest.java
🚧 Files skipped from review as they are similar to previous changes (3)
  • core/src/main/java/com/linecorp/armeria/common/ReproducibleHttpRequest.java
  • core/src/main/java/com/linecorp/armeria/common/HttpRequest.java
  • core/src/test/java/com/linecorp/armeria/client/ReproducibleHttpRequestClientTest.java

The private static abort(HttpRequest, Throwable) helper was an overload of
abort()/abort(Throwable) with abortAll() declared between them, tripping
checkstyle's OverloadMethodsDeclarationOrder. Rename the helper to abortQuietly
so it is no longer an overload; no behavior change.

Co-authored-by: Isaac

@jrhee17 jrhee17 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly looks good, only nits and a question for me

* {@code duplicate}, and {@code duplicate} then throws instead of returning a request that would never
* be torn down.
*/
public final class ReproducibleHttpRequestDuplicator implements HttpRequestDuplicator {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question) Does this have to be in the internal package? Can this class be in the common pkg with ReproducibleHttpRequest?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — moved it to com.linecorp.armeria.common and made both the class and constructor package-private. It was only public to be reachable across the package boundary from ReproducibleHttpRequest#toDuplicator, and it has no external consumers, so this matches the sibling DefaultHttpRequestDuplicator exactly. Done in the latest push.

final HttpRequest produced = HttpRequest.of(newHeaders, body);

final Throwable abortCause;
synchronized (this) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note) synchronized seems to no longer have the thread pinning issue since Java 24

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good note. The lock here never wraps a blocking call — the factory (bodyFactory.get(), the only potentially-blocking/file-opening call) runs outside it, and the critical sections only mutate an in-memory IdentityHashMap set. So pinning isn't a concern even pre-Java-24. No change needed.

*/
private static StreamMessage<HttpObject> lazyBody(
Supplier<? extends StreamMessage<? extends HttpObject>> bodyFactory) {
return StreamMessage.of((Publisher<HttpObject>) subscriber -> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question) Is it necessary that the supplier isn't called eagerly? Asking because from what I understand, StreamMessage usually don't open resources until a downstream subscription is done.

If it is necessary for your use-case, I think it's fine to go ahead with this impl. I think it may be possible to generalize DeferredStreamMessage later on s.t. we can create a lazy supplier StreamMessage variant.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Laziness is necessary here: the caller's factory may have subscribe-time side effects (opening a file, acquiring a handle), and on the retry/redirect path the delegate is never subscribed — toDuplicator(...) drives the factory directly. An eager bodyFactory.get() at construction would materialize and leak a body on every reproducible request. The lazy wrapper confines materialization to the direct-consume path. Agreed that generalizing DeferredStreamMessage into a lazy-supplier StreamMessage variant would be cleaner and remove the dropped-SubscriptionOptions caveat — I'd like to do that as a follow-up rather than expand this PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks

I'd like to do that as a follow-up rather than expand this PR.

Feel free to go ahead, or we can handle this as well if you are busy 🙇 Let us know

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That'd be great — please go ahead with the DeferredStreamMessage generalization on your side. I'll keep the hand-rolled lazy body in this PR (its one caveat, dropped SubscriptionOptions, only affects the cold direct-consume path), and it can be swapped over to the generalized variant once yours lands. Thanks for offering to take it!

…cs+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
@jrhee17

jrhee17 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

I'm not sure you are ready for the next round of reviews - let me know if you are 🙇

@yzfeng2020

Copy link
Copy Markdown
Contributor Author

thanks for the comments so far, I will re-request when ready!

… package-private

The duplicator was public only to be reachable across the internal.common ->
common package boundary from ReproducibleHttpRequest#toDuplicator. It has no
external consumers, so co-locating it with its sole caller in
com.linecorp.armeria.common lets it (and its constructor) become package-private,
matching the sibling DefaultHttpRequestDuplicator. No behavior change.

Co-authored-by: Isaac
…d 303

Lock in the reproducibility contracts that were previously only asserted in prose:

- factoryInvokedOnceOnDirectConsumption: direct consumption (no retry/redirect
  decorator) subscribes the lazy delegate and runs the factory exactly once.
- abortBeforeSubscriptionInvokesFactoryOnce: aborting a never-sent request runs
  the factory exactly once to release the produced body, and never regenerates.
- seeOtherRedirectDropsBody now asserts the factory is called exactly once: the
  303 hop drops the body (aborting the duplicator) instead of regenerating it,
  addressing the missing-assertion review note.

Co-authored-by: Isaac
@yzfeng2020
yzfeng2020 force-pushed the feature/reproducible-request-bodies branch from f8e4295 to 1286461 Compare August 4, 2026 03:36
…hten retry assert

Address code-review findings (all test-only, no production change):

- duplicateAfterAbortTearsDownProducedBodyWithCause: exercises the abortCause
  propagation branch of the duplicate()/abort(cause) race — the just-produced body
  is torn down with the remembered cause and duplicate() throws. Previously only the
  close() variant (abortCause == null) was covered.
- factoryThrowingOnRedirectHopFailsFast: a factory that throws while regenerating the
  body for a redirect hop fails the request fast via RedirectingClient's try/catch,
  mirroring the existing RetryingClient coverage.
- retriesAfterFirstAttemptBodyFailsMidStream: assert exactly 2 factory calls instead
  of >= 2, so an over-regeneration bug is caught rather than masked.

Co-authored-by: Isaac
…dundant test

Second-round review follow-up (test-only):

- concurrentAbortDuringDuplicateTearsDownProducedBody: a barrier-gated test that
  actually runs duplicate() on one thread while abort(cause) runs on another, forcing
  the interleave the instance lock exists for. Verified non-vacuous by mutation: with
  the synchronized guards removed it fails (the produced body leaks and duplicate()
  does not throw). The prior sequential test only covered the non-interleaved branch.
- Remove streamsBodyWithoutAccumulatingIt: a 64 KiB aggregate is delivered identically
  by a buffering duplicator, so it could not detect a regression to buffering. The
  companion toDuplicatorIgnoresMaxRequestLength (cap=8) is the real non-buffering proof,
  and body integrity is already covered by everyDuplicateProducesAFreshBody.

Co-authored-by: Isaac
@ikhoon ikhoon modified the milestones: 1.41.0, 1.42.0 Aug 4, 2026
@yzfeng2020

Copy link
Copy Markdown
Contributor Author

@jrhee17 this is ready for another round — I've replied to all the open threads and pushed follow-up changes:

  • Moved ReproducibleHttpRequestDuplicator into common and made it package-private (matching DefaultHttpRequestDuplicator).
  • Kept the single reproducible(...) method, the lazy body factory, and the independent-children duplicator semantics — rationale in the thread replies.
  • Hardened the abort/duplicate race coverage: added a sequential teardown test plus a barrier-gated, genuinely multi-threaded concurrentAbortDuringDuplicateTearsDownProducedBody (mutation-verified — removing the synchronized guards makes it fail), and tightened the retry/redirect factory-count assertions.

Thanks for the thorough review!

@yzfeng2020
yzfeng2020 requested a review from jrhee17 August 4, 2026 17:29

@jrhee17 jrhee17 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me overall 👍

// aborted request and wasting the retry budget.
final StreamMessage<? extends HttpObject> body =
requireNonNull(bodyFactory.get(), "bodyFactory.get() returned null.");
final HttpRequest produced = HttpRequest.of(newHeaders, body);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question) Is there a reason we aren't creating another reproducible request here?

Suggested change
final HttpRequest produced = HttpRequest.of(newHeaders, body);
final HttpRequest produced = HttpRequest.reproducible(newHeaders, bodyFactory);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prototyped this — returning HttpRequest.defer(newHeaders, bodyFactory) from duplicate() so each child is itself lazy/deferred. It's appealing (it would delete the child-tracking + lock + abort-teardown, and all 16 end-to-end retry/redirect tests still passed), but it regresses the fail-fast guarantee, so I kept the eager HttpRequest.of(newHeaders, body).

The reason: eager duplicate() distinguishes "the factory is broken" (throw now, fail the request) from "the wire faulted" (retryable). With a lazy child the factory only runs at subscribe time, so a broken factory becomes indistinguishable from a transport fault. I measured it — a factory that always throws, under RetryRule.onException():

  • eager (current): factory called once, fails fast;
  • lazy child: factory called , burning the entire retry budget on a factory that can never succeed.

So the child-tracking/lock machinery is load-bearing: it's what buys fail-fast. Happy to revisit if you'd like, but that's why I've left it eager.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flagging in case it crossed with your note: I replied above with the outcome of prototyping this. I did build the "each duplicate() returns a reproducible child" version — it deletes the child-tracking/lock/teardown and all 16 end-to-end tests pass — but it regresses fail-fast: a broken factory under RetryRule.onException() burns the whole retry budget (measured 7× vs 1×) instead of failing immediately, because a lazy child can't distinguish "factory broken" from "wire faulted". So I've kept the eager duplicate(). Happy to revisit if you'd still prefer the lazy version knowing that trade-off.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So there's a trade-off. I understand it. So we can probably leave it as is and revisit this if we need to make the child reproducible also. @jrhee17 What do you think of it?

Comment on lines +421 to +423
static HttpRequest reproducible(
RequestHeaders headers,
Supplier<? extends StreamMessage<? extends HttpObject>> bodyFactory) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question) I'm unsure about the naming. What do you think of defer? It seems like the API looks similar to Flux#defer, and we'll likely be adding a StreamMessage#defer as well.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@minwoox suggested that we keep the name reproducible since users may mistakenly think it may be safe to call subscribe HttpRequest#reproducible multiple times

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea — renamed to defer. I've taken it all the way through: the public @UnstableApi method is now HttpRequest.defer(...), and the internal classes are DeferredHttpRequest / DeferredHttpRequestDuplicator (plus the test classes) so the vocabulary is consistent end to end. "Regenerate" stays as the verb for producing an equivalent body per attempt, since that describes the actual behavior where "defer" wouldn't fit. Pushed in 8a66f3e0e.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry but, could we revert to HttpRequest.reproducible(...)?

StreamMessage is single-subscription by contract, so the Flux.defer mental model (re-subscribe → regenerate) doesn't apply here — you can't re-subscribe. So defer names a property that isn't the point.

What actually defines this type is the opposite constraint: each call must supply an
equivalent StreamMessage (same bytes and trailers). reproducible names
that contract directly; defer only says "later" and says nothing about equivalence.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and reverted to HttpRequest.reproducible(...) (pushed in 0cd4d64e8). You and @minwoox are right — since StreamMessage is single-subscription, the Flux#defer mental model doesn't carry over, and reproducible names the equivalent-body contract directly rather than just "later". The internal ReproducibleHttpRequest* classes and tests are back too. I kept the content-length doc scoping from the reverted commit (the framing warning now applies only when the caller sets content-length explicitly).

* {@code duplicate}, and {@code duplicate} then throws instead of returning a request that would never
* be torn down.
*/
final class ReproducibleHttpRequestDuplicator implements HttpRequestDuplicator {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note) My understanding is that the purpose of this class is to sync the lifecycle of HttpRequestDuplicator with the lifecycle of the upstream+child streams.

e.g. HttpRequestDuplicator#abort or HttpRequestDuplicator#close cleans up upstream+child streams.

This usage pattern is documented in the javadocs for HttpRequestDuplicator, and RetryingClient uses HttpRequestDuplicator#abort to close all child/upstream streams at once.


Also, there is a small nuance that DefaultHttpRequestDuplicator assumes the upstream lifecycle is synced with the duplicator. (since the upstream subscribes to the duplicator internally)
For the reproducible case, this won't be true - calling toDuplicator won't subscribe to the upstream.

However, since we never call bodyFactory#get in the first place, there likely isn't anything to leak so there should be no issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One clarification on the premise: in the current (eager) design duplicate() does call bodyFactory.get() eagerly, so a produced-but-unsubscribed child does hold a resource — which is exactly why the child-set + abort-teardown exists. Your conclusion (no leak) is right, but it holds because abort()/close() tear those children down, not because the factory is never called.

I looked at making children lazy (so nothing is produced until subscribed, matching your mental model) — see my reply on the duplicate() thread — but it regresses fail-fast: a broken factory under onException() burns the full retry budget (measured 7× vs 1×). So I've kept eager production + teardown. And you're right that the upstream-lifecycle assumption DefaultHttpRequestDuplicator makes doesn't apply here (toDuplicator never subscribes an upstream); the teardown covers the eagerly-produced children instead.

*/
private static StreamMessage<HttpObject> lazyBody(
Supplier<? extends StreamMessage<? extends HttpObject>> bodyFactory) {
return StreamMessage.of((Publisher<HttpObject>) subscriber -> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks

I'd like to do that as a follow-up rather than expand this PR.

Feel free to go ahead, or we can handle this as well if you are busy 🙇 Let us know

* re-validated against the regenerated body. A factory whose output varies across invocations
* (e.g. it embeds a timestamp, or reads a file being mutated concurrently) silently sends
* different data on a retry or redirect, and a length mismatch against a declared
* {@code content-length} corrupts wire framing (the request stalls or the next message is

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question) Did you actually encounter any content-length related issues? Asking bc I think we set content-length right before a req goes through the wire (unless a user explicitly set it)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I didn't hit it in practice — it was a precautionary note, and you're right that it overstated the risk. A streaming request without an explicit content-length goes out chunked (self-delimiting), so a body-length difference between attempts is harmless to framing. I've scoped the caveat down to the one case where it actually applies: when the caller sets content-length explicitly in the fixed headers, that value is reused verbatim and not re-validated, so a length mismatch corrupts framing. Updated in 8a66f3e0e.

…th caveat

Address jrhee17's review round:

- Rename the public @UnstableApi factory HttpRequest.reproducible(...) to
  HttpRequest.defer(...), aligning with Reactor's Flux#defer and a future
  StreamMessage#defer. Rename the internal ReproducibleHttpRequest ->
  DeferredHttpRequest and ReproducibleHttpRequestDuplicator ->
  DeferredHttpRequestDuplicator (plus the three test classes) so the vocabulary
  is consistent end to end. "Regenerate" remains the verb for producing an
  equivalent body per attempt, where "defer" (laziness) would not fit.
- Scope the content-length caveat: a streaming request without an explicit
  content-length is sent chunked (self-delimiting), so a body-length difference
  is harmless to framing. The framing-corruption warning now applies only to the
  case where the caller sets content-length explicitly.

No behavioral change; the eager per-attempt duplicator design is unchanged.

Co-authored-by: Isaac
@jrhee17

jrhee17 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Sorry @yzfeng2020 but it seems like there are some nuances where the behavior is slightly different from Flux#defer. Do you mind

  1. Reverting back to naming as reproducible
  2. Reproducible request bodies #6841 (comment)

Once these two are addressed, I think this PR looks done to me

…ntent-length fix

jrhee17 and minwoox asked to revert the `defer` rename: StreamMessage is
single-subscription by contract, so the Flux#defer mental model (re-subscribe ->
regenerate) does not apply, and `defer` says nothing about the equivalent-body
contract that actually defines this type. `reproducible` names that contract
directly.

This fully reverts commit 8a66f3e's rename — HttpRequest.defer(...) is back to
HttpRequest.reproducible(...), and the DeferredHttpRequest* classes/tests are back
to ReproducibleHttpRequest*. The content-length caveat scoping from that commit is
retained (the framing warning applies only when the caller sets content-length
explicitly; otherwise the request is chunked and self-delimiting).

Co-authored-by: Isaac
…ore asserting

The BlockHound CI profile hit a latent race: after `aggregate().join()` returns,
the request's own `whenComplete()` callback can fire slightly later on the event
loop, so `assertThat(whenComplete()).isCompleted()` occasionally saw an incomplete
future. Join `whenComplete()` first to make the assertion deterministic, matching
the join-based pattern used elsewhere in the file.

Co-authored-by: Isaac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ContentTooLargeException when using RetryingClient or RedirectingClient

4 participants