From cc76049fc0035f52d47e2cc477d5ce147ca1f01d Mon Sep 17 00:00:00 2001 From: Thomas Segismont Date: Tue, 24 Mar 2026 15:51:05 +0100 Subject: [PATCH] Support HTTP trailers forwarding Related to #132 Forward HTTP trailers from backend response to proxy response when the response body is not modified. Some portions of this content were created with the assistance of Claude Code. Signed-off-by: Thomas Segismont --- .../io/vertx/httpproxy/impl/HttpUtils.java | 8 ++- .../vertx/httpproxy/impl/ProxiedResponse.java | 25 +++++--- src/test/java/io/vertx/tests/ProxyTest.java | 61 ++++++++++++++++++- 3 files changed, 84 insertions(+), 10 deletions(-) diff --git a/src/main/java/io/vertx/httpproxy/impl/HttpUtils.java b/src/main/java/io/vertx/httpproxy/impl/HttpUtils.java index 4aa83f4..c392206 100644 --- a/src/main/java/io/vertx/httpproxy/impl/HttpUtils.java +++ b/src/main/java/io/vertx/httpproxy/impl/HttpUtils.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2011-2020 Contributors to the Eclipse Foundation + * Copyright (c) 2011-2026 Contributors to the Eclipse Foundation * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License 2.0 which is available at @@ -12,6 +12,7 @@ import io.vertx.core.MultiMap; import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpServerResponse; import java.time.Instant; import java.util.List; @@ -52,4 +53,9 @@ static Instant dateHeader(MultiMap headers) { return ParseUtils.parseHeaderDate(dateHeader); } } + + public static boolean trailersSupported(HttpServerResponse proxiedResponse) { + return proxiedResponse.streamId() >= 0 // HTTP/2 and HTTP/3 + || proxiedResponse.isChunked(); // Required for HTTP/1.1 + } } diff --git a/src/main/java/io/vertx/httpproxy/impl/ProxiedResponse.java b/src/main/java/io/vertx/httpproxy/impl/ProxiedResponse.java index 2d255ee..5c98d3b 100644 --- a/src/main/java/io/vertx/httpproxy/impl/ProxiedResponse.java +++ b/src/main/java/io/vertx/httpproxy/impl/ProxiedResponse.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2011-2025 Contributors to the Eclipse Foundation + * Copyright (c) 2011-2026 Contributors to the Eclipse Foundation * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License 2.0 which is available at @@ -21,7 +21,6 @@ import io.vertx.core.streams.Pipe; import io.vertx.core.streams.ReadStream; import io.vertx.httpproxy.Body; -import io.vertx.httpproxy.MediaType; import io.vertx.httpproxy.ProxyRequest; import io.vertx.httpproxy.ProxyResponse; @@ -266,15 +265,25 @@ public ProxyResponse release() { private Future sendResponse(ReadStream body) { Pipe pipe = body.pipe(); - pipe.endOnSuccess(true); + pipe.endOnSuccess(false); pipe.endOnFailure(false); return pipe .to(proxiedResponse) + .compose(v -> { + // Only forward trailers if using the original backend response stream + if (body.equals(response)) { + MultiMap trailers = response.trailers(); + if (!trailers.isEmpty() && HttpUtils.trailersSupported(proxiedResponse)) { + proxiedResponse.trailers().addAll(trailers); + } + } + return proxiedResponse.end(); + }) .andThen(ar -> { - if (ar.failed()) { - request.request.reset(); - proxiedResponse.reset(); - } - }); + if (ar.failed()) { + request.request.reset(); + proxiedResponse.reset(); + } + }); } } diff --git a/src/test/java/io/vertx/tests/ProxyTest.java b/src/test/java/io/vertx/tests/ProxyTest.java index d55bd3a..113591d 100644 --- a/src/test/java/io/vertx/tests/ProxyTest.java +++ b/src/test/java/io/vertx/tests/ProxyTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2011-2025 Contributors to the Eclipse Foundation + * Copyright (c) 2011-2026 Contributors to the Eclipse Foundation * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License 2.0 which is available at @@ -231,4 +231,63 @@ public Future handleProxyRequest(ProxyContext context) { ) .onComplete(ctx.asyncAssertSuccess(buffer -> ctx.assertEquals("HOLA", buffer.toString()))); } + + @Test + public void testTrailersForwarding(TestContext ctx) { + testTrailers(ctx, null); + } + + @Test + public void testTrailersNotForwardedWhenBodyIsModified(TestContext ctx) { + testTrailers(ctx, new ProxyInterceptor() { + @Override + public Future handleProxyResponse(ProxyContext context) { + ProxyResponse proxyResponse = context.response(); + proxyResponse.setBody(Body.body(io.vertx.core.buffer.Buffer.buffer("modified body"))); + return context.sendResponse(); + } + }); + } + + @Test + public void testTrailersNotForwardedWhenBodyIsTransformed(TestContext ctx) { + ProxyInterceptor interceptor = ProxyInterceptor.builder() + .transformingResponseBody(BodyTransformers.text(String::toUpperCase, "UTF-8")) + .build(); + testTrailers(ctx, interceptor); + } + + private void testTrailers(TestContext ctx, ProxyInterceptor interceptor) { + SocketAddress backend = startHttpBackend(ctx, 8081, req -> { + req.response() + .setChunked(true) + .putHeader("content-type", "text/plain") + .write("response body"); + req.response().trailers() + .add("X-Custom-Trailer", "trailer-value") + .add("X-Another-Trailer", "another-value"); + req.response().end(); + }); + startProxy(proxy -> { + proxy.origin(backend); + if (interceptor != null) { + proxy.addInterceptor(interceptor); + } + }); + client = vertx.createHttpClient(); + client.request(HttpMethod.GET, 8080, "localhost", "/") + .compose(req -> req.send().compose(resp -> { + ctx.assertEquals(200, resp.statusCode()); + return resp.body().map(body -> resp); + })) + .onComplete(ctx.asyncAssertSuccess(resp -> { + if (interceptor == null) { + ctx.assertEquals("trailer-value", resp.trailers().get("X-Custom-Trailer")); + ctx.assertEquals("another-value", resp.trailers().get("X-Another-Trailer")); + } else { + ctx.assertNull(resp.trailers().get("X-Custom-Trailer")); + ctx.assertNull(resp.trailers().get("X-Another-Trailer")); + } + })); + } }