|
28 | 28 |
|
29 | 29 | import com.linecorp.armeria.client.retry.RetryRule; |
30 | 30 | import com.linecorp.armeria.client.retry.RetryingClient; |
| 31 | +import com.linecorp.armeria.common.AggregatedHttpRequest; |
31 | 32 | import com.linecorp.armeria.common.AggregatedHttpResponse; |
| 33 | +import com.linecorp.armeria.common.CommonPools; |
32 | 34 | import com.linecorp.armeria.common.ExchangeType; |
33 | 35 | import com.linecorp.armeria.common.HttpData; |
34 | 36 | import com.linecorp.armeria.common.HttpHeaderNames; |
35 | 37 | import com.linecorp.armeria.common.HttpHeaders; |
36 | 38 | import com.linecorp.armeria.common.HttpMethod; |
37 | 39 | import com.linecorp.armeria.common.HttpObject; |
38 | 40 | import com.linecorp.armeria.common.HttpRequest; |
| 41 | +import com.linecorp.armeria.common.HttpRequestDuplicator; |
39 | 42 | import com.linecorp.armeria.common.HttpResponse; |
40 | 43 | import com.linecorp.armeria.common.HttpStatus; |
41 | 44 | import com.linecorp.armeria.common.MediaType; |
|
45 | 48 | import com.linecorp.armeria.server.ServerBuilder; |
46 | 49 | import com.linecorp.armeria.testing.junit5.server.ServerExtension; |
47 | 50 |
|
| 51 | +import io.netty.util.concurrent.EventExecutor; |
| 52 | + |
48 | 53 | class ReproducibleHttpRequestClientTest { |
49 | 54 |
|
50 | 55 | private static final AtomicInteger serverHits = new AtomicInteger(); |
@@ -93,6 +98,24 @@ protected void configure(ServerBuilder sb) { |
93 | 98 | req.aggregate().thenApply(agg -> AggregatedHttpResponse.of( |
94 | 99 | HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, |
95 | 100 | 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 | + }))); |
96 | 119 | } |
97 | 120 | }; |
98 | 121 |
|
@@ -145,9 +168,12 @@ void retryRegeneratesBody() { |
145 | 168 |
|
146 | 169 | assertThat(res.status()).isEqualTo(HttpStatus.OK); |
147 | 170 | 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); |
149 | 175 | // Body regenerated for the initial attempt and the retry. |
150 | | - assertThat(bodyCalls).hasValueGreaterThanOrEqualTo(2); |
| 176 | + assertThat(bodyCalls).hasValue(2); |
151 | 177 | } |
152 | 178 |
|
153 | 179 | @Test |
@@ -179,8 +205,9 @@ void retryReproducesMultiChunkBodyAndTrailers() { |
179 | 205 | assertThat(res.status()).isEqualTo(HttpStatus.OK); |
180 | 206 | // Concatenated interior chunks (in order) plus the reproduced trailer, on the re-sent attempt. |
181 | 207 | 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); |
184 | 211 | } |
185 | 212 |
|
186 | 213 | @Test |
@@ -238,8 +265,9 @@ void followsRedirectRegeneratingBody() { |
238 | 265 |
|
239 | 266 | assertThat(res.status()).isEqualTo(HttpStatus.OK); |
240 | 267 | 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); |
243 | 271 | } |
244 | 272 |
|
245 | 273 | @Test |
@@ -291,7 +319,124 @@ void stackedRetryAndRedirect() { |
291 | 319 |
|
292 | 320 | assertThat(res.status()).isEqualTo(HttpStatus.OK); |
293 | 321 | 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); |
296 | 441 | } |
297 | 442 | } |
0 commit comments