diff --git a/client/src/jmh/java/org/asynchttpclient/bench/StoredCookieRequestBenchmark.java b/client/src/jmh/java/org/asynchttpclient/bench/StoredCookieRequestBenchmark.java
new file mode 100644
index 000000000..d4cea91cb
--- /dev/null
+++ b/client/src/jmh/java/org/asynchttpclient/bench/StoredCookieRequestBenchmark.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient.bench;
+
+import io.netty.handler.codec.http.cookie.DefaultCookie;
+import org.asynchttpclient.AsyncCompletionHandlerBase;
+import org.asynchttpclient.AsyncHttpClient;
+import org.asynchttpclient.ListenableFuture;
+import org.asynchttpclient.Request;
+import org.asynchttpclient.RequestBuilder;
+import org.asynchttpclient.Response;
+import org.asynchttpclient.cookie.ThreadSafeCookieStore;
+import org.asynchttpclient.exception.FilterException;
+import org.asynchttpclient.filter.FilterContext;
+import org.asynchttpclient.filter.RequestFilter;
+import org.asynchttpclient.uri.Uri;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+import static org.asynchttpclient.Dsl.asyncHttpClient;
+import static org.asynchttpclient.Dsl.config;
+
+/**
+ * Measures {@code DefaultAsyncHttpClient.executeRequest} up to the request filters, for a request whose URL has a
+ * cookie in the client's cookie store ({@code storedCookie}) and for one whose URL has none ({@code noStoredCookie}).
+ * The difference between the two is what adding the stored cookies to the request costs.
+ *
+ * A filter ends each call by throwing a preallocated exception, so nothing reaches the network; the failed future
+ * that {@code executeRequest} then builds is the same in both arms.
+ */
+@State(Scope.Thread)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+public class StoredCookieRequestBenchmark {
+
+ private static final FilterException STOP = new FilterException("stop");
+
+ @Param({"4", "16"})
+ public int headerCount;
+
+ private AsyncHttpClient client;
+ private Request withStoredCookie;
+ private Request withoutStoredCookie;
+ private AsyncCompletionHandlerBase handler;
+
+ @Setup(Level.Trial)
+ public void setup() {
+ ThreadSafeCookieStore cookieStore = new ThreadSafeCookieStore();
+ DefaultCookie session = new DefaultCookie("SESSION", "0123456789abcdef0123456789abcdef");
+ session.setPath("/");
+ cookieStore.add(Uri.create("https://api.example.com/"), session);
+
+ RequestFilter stop = new RequestFilter() {
+ @Override
+ public FilterContext filter(FilterContext ctx) throws FilterException {
+ throw STOP;
+ }
+ };
+ client = asyncHttpClient(config().setCookieStore(cookieStore).addRequestFilter(stop));
+ withStoredCookie = request("https://api.example.com/v1/items?page=2&size=50");
+ withoutStoredCookie = request("https://other.example.org/v1/items?page=2&size=50");
+ handler = new AsyncCompletionHandlerBase() {
+ @Override
+ public void onThrowable(Throwable t) {
+ }
+ };
+ }
+
+ private Request request(String url) {
+ RequestBuilder builder = new RequestBuilder("GET").setUrl(url);
+ for (int i = 0; i < headerCount; i++) {
+ builder.addHeader("X-Header-" + i, "value-" + i);
+ }
+ return builder.build();
+ }
+
+ @TearDown(Level.Trial)
+ public void tearDown() throws IOException {
+ client.close();
+ }
+
+ @Benchmark
+ public ListenableFuture storedCookie() {
+ return client.executeRequest(withStoredCookie, handler);
+ }
+
+ @Benchmark
+ public ListenableFuture noStoredCookie() {
+ return client.executeRequest(withoutStoredCookie, handler);
+ }
+}
diff --git a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClient.java b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClient.java
index 3b417a5a3..3535fd4b2 100644
--- a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClient.java
+++ b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClient.java
@@ -34,6 +34,8 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
@@ -50,6 +52,7 @@
import static org.asynchttpclient.util.HttpConstants.Methods.POST;
import static org.asynchttpclient.util.HttpConstants.Methods.PUT;
import static org.asynchttpclient.util.HttpConstants.Methods.TRACE;
+import static org.asynchttpclient.util.MiscUtils.isNonEmpty;
/**
* Default and threadsafe implementation of {@link AsyncHttpClient}.
@@ -233,11 +236,7 @@ public ListenableFuture executeRequest(Request request, AsyncHandler h
try {
List cookies = config.getCookieStore().get(request.getUri());
if (!cookies.isEmpty()) {
- RequestBuilder requestBuilder = request.toBuilder();
- for (Cookie cookie : cookies) {
- requestBuilder.addCookieIfUnset(cookie);
- }
- request = requestBuilder.build();
+ request = addCookiesIfUnset(request, cookies);
}
} catch (Exception e) {
handler.onThrowable(e);
@@ -275,6 +274,44 @@ public ListenableFuture executeRequest(RequestBuilder requestBuilder)
return executeRequest(requestBuilder.build());
}
+ /**
+ * Adds each stored cookie whose name the request does not carry yet, keeping the first of a name. A
+ * {@link DefaultRequest} is copied with only its cookie list replaced, which leaves its URI as built; any other
+ * {@link Request} goes through {@link Request#toBuilder()}, as it may override what the builder copies.
+ */
+ private static Request addCookiesIfUnset(Request request, List storedCookies) {
+ if (request.getClass() != DefaultRequest.class) {
+ RequestBuilder requestBuilder = request.toBuilder();
+ for (Cookie cookie : storedCookies) {
+ requestBuilder.addCookieIfUnset(cookie);
+ }
+ return requestBuilder.build();
+ }
+
+ // A DefaultRequest from the public constructor may carry null, which the builder path treated as empty.
+ List requestCookies = isNonEmpty(request.getCookies()) ? request.getCookies() : Collections.emptyList();
+ List cookies = null;
+ for (Cookie cookie : storedCookies) {
+ if (!hasCookieNamed(cookies == null ? requestCookies : cookies, cookie.name())) {
+ if (cookies == null) {
+ cookies = new ArrayList<>(requestCookies.size() + storedCookies.size());
+ cookies.addAll(requestCookies);
+ }
+ cookies.add(cookie);
+ }
+ }
+ return cookies == null ? request : ((DefaultRequest) request).withCookies(cookies);
+ }
+
+ private static boolean hasCookieNamed(List cookies, String name) {
+ for (Cookie cookie : cookies) {
+ if (cookie.name().equals(name)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
private ListenableFuture execute(Request request, final AsyncHandler asyncHandler) {
try {
return requestSender.sendRequest(request, asyncHandler, null);
diff --git a/client/src/main/java/org/asynchttpclient/DefaultRequest.java b/client/src/main/java/org/asynchttpclient/DefaultRequest.java
index 7df31fb95..90e883e58 100644
--- a/client/src/main/java/org/asynchttpclient/DefaultRequest.java
+++ b/client/src/main/java/org/asynchttpclient/DefaultRequest.java
@@ -181,6 +181,19 @@ public DefaultRequest(String method,
this.nameResolver = nameResolver;
}
+ /**
+ * Returns a request that differs from this one only by its cookies. Unlike a round trip through
+ * {@link #toBuilder()}, the URI is kept as built rather than encoded again, so a request built with URL
+ * encoding disabled is sent as written, and the other fields are shared rather than copied.
+ */
+ DefaultRequest withCookies(List cookies) {
+ return new DefaultRequest(method, uri, address, localAddress, headers, cookies, byteData, compositeByteData,
+ stringData, byteBufferData, byteBufData, streamData, bodyGenerator, formParams, bodyParts, virtualHost,
+ proxyServer, realm, file, followRedirect, requestTimeout, readTimeout, rangeOffset, charset,
+ channelPoolPartitioning, nameResolver, useAbsoluteRequestDeadline, refuseSchemeDowngradeOnRedirect,
+ refuseCrossOriginBodyOnRedirect);
+ }
+
@Override
public String getUrl() {
return uri.toUrl();
diff --git a/client/src/test/java/org/asynchttpclient/DefaultAsyncHttpClientTest.java b/client/src/test/java/org/asynchttpclient/DefaultAsyncHttpClientTest.java
index 3fd73a1ba..956c1a322 100644
--- a/client/src/test/java/org/asynchttpclient/DefaultAsyncHttpClientTest.java
+++ b/client/src/test/java/org/asynchttpclient/DefaultAsyncHttpClientTest.java
@@ -28,25 +28,48 @@
import io.netty.channel.nio.NioIoHandler;
import io.netty.channel.uring.IoUring;
import io.netty.channel.uring.IoUringIoHandler;
+import io.netty.handler.codec.http.cookie.Cookie;
+import io.netty.handler.codec.http.cookie.DefaultCookie;
import io.netty.util.Timer;
import org.asynchttpclient.cookie.CookieEvictionTask;
import org.asynchttpclient.cookie.CookieStore;
import org.asynchttpclient.cookie.ThreadSafeCookieStore;
+import org.asynchttpclient.exception.FilterException;
+import org.asynchttpclient.filter.FilterContext;
+import org.asynchttpclient.filter.RequestFilter;
import org.asynchttpclient.testserver.HttpServer;
+import org.asynchttpclient.uri.Uri;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledOnOs;
import org.junit.jupiter.api.condition.OS;
import java.io.IOException;
+import java.net.InetAddress;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE;
+import static java.nio.charset.StandardCharsets.UTF_16;
import static org.asynchttpclient.Dsl.asyncHttpClient;
+import static org.asynchttpclient.Dsl.basicAuthRealm;
import static org.asynchttpclient.Dsl.config;
+import static org.asynchttpclient.Dsl.get;
+import static org.asynchttpclient.Dsl.post;
+import static org.asynchttpclient.Dsl.proxyServer;
import static org.asynchttpclient.test.TestUtils.createSslEngineFactory;
+import static org.asynchttpclient.util.HttpConstants.Methods.GET;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
@@ -280,4 +303,182 @@ public void testDisablingCookieStore() throws IOException {
//
}
}
+
+ @Test
+ public void testStoredCookiesKeepRawUrlAsBuilt() throws IOException {
+ String url = "http://localhost/a|b?q={x}";
+ Request request = new RequestBuilder(GET, true).setUrl(url).build();
+ assertEquals(url, request.getUrl());
+ assertNotEquals(url, new RequestBuilder(GET).setUrl(url).build().getUrl(), "the URL must be one encoding would change");
+
+ Request sent = sendWithStoredCookies(request, cookie("session", "s"));
+
+ assertEquals(url, sent.getUrl());
+ assertEquals(List.of("session=s"), cookieStrings(sent));
+ }
+
+ @Test
+ public void testStoredCookiesDoNotReplaceRequestCookies() throws IOException {
+ Request request = get("http://localhost/").addCookie(cookie("a", "request")).build();
+
+ Request sent = sendWithStoredCookies(request, cookie("a", "store"), cookie("b", "store"));
+
+ assertEquals(List.of("a=request", "b=store"), cookieStrings(sent));
+ assertEquals(List.of("a=request"), cookieStrings(request));
+ }
+
+ @Test
+ public void testStoredCookiesAlreadySetLeaveRequestAsIs() throws IOException {
+ Request request = get("http://localhost/").addCookie(cookie("a", "request")).build();
+
+ Request sent = sendWithStoredCookies(request, cookie("a", "store"));
+
+ assertSame(request, sent);
+ }
+
+ @Test
+ public void testStoredCookiesKeepFirstStoredCookieOfAName() throws IOException {
+ Request request = get("http://localhost/x/y").build();
+ DefaultCookie longerPath = new DefaultCookie("a", "longer-path");
+ longerPath.setPath("/x");
+
+ Request sent = sendWithStoredCookies(request, cookie("a", "root-path"), longerPath);
+
+ assertEquals(List.of("a=longer-path"), cookieStrings(sent));
+ }
+
+ @Test
+ public void testStoredCookiesKeepOtherRequestFields() throws Exception {
+ // The flags are adjacent Boolean parameters of DefaultRequest: each pair differs in one of the two requests.
+ RequestBuilder stringBody = post("http://localhost/path?x=1")
+ .setHeader(CONTENT_TYPE, "text/plain")
+ .setBody("body")
+ .setFollowRedirect(true)
+ .setUseAbsoluteRequestDeadline(false)
+ .setRefuseCrossOriginBodyOnRedirect(true);
+ RequestBuilder formBody = post("http://localhost/path?x=1")
+ .addFormParam("p", "v")
+ .setUseAbsoluteRequestDeadline(true)
+ .setRefuseSchemeDowngradeOnRedirect(false)
+ .setRefuseCrossOriginBodyOnRedirect(false);
+
+ for (RequestBuilder builder : List.of(stringBody, formBody)) {
+ Request request = builder
+ .setAddress(InetAddress.getByAddress("localhost", new byte[]{127, 0, 0, 1}))
+ .setLocalAddress(InetAddress.getByAddress(new byte[]{127, 0, 0, 2}))
+ .setHeader("X-Custom", "v")
+ .setVirtualHost("vhost")
+ .setProxyServer(proxyServer("proxy", 8080))
+ .setRealm(basicAuthRealm("user", "pass"))
+ .setRequestTimeout(Duration.ofSeconds(3))
+ .setReadTimeout(Duration.ofSeconds(2))
+ .setCharset(UTF_16)
+ .build();
+
+ Request sent = sendWithStoredCookies(request, cookie("session", "s"));
+
+ assertNotSame(request, sent);
+ assertEquals(List.of("session=s"), cookieStrings(sent));
+ assertEquals(request.getMethod(), sent.getMethod());
+ assertEquals(request.getUri(), sent.getUri());
+ assertEquals(request.getAddress(), sent.getAddress());
+ assertEquals(request.getLocalAddress(), sent.getLocalAddress());
+ assertEquals(request.getHeaders().entries().toString(), sent.getHeaders().entries().toString());
+ assertSame(request.getByteData(), sent.getByteData());
+ assertSame(request.getCompositeByteData(), sent.getCompositeByteData());
+ assertSame(request.getStringData(), sent.getStringData());
+ assertSame(request.getByteBufferData(), sent.getByteBufferData());
+ assertSame(request.getByteBufData(), sent.getByteBufData());
+ assertSame(request.getStreamData(), sent.getStreamData());
+ assertSame(request.getBodyGenerator(), sent.getBodyGenerator());
+ assertEquals(request.getFormParams(), sent.getFormParams());
+ assertEquals(request.getBodyParts(), sent.getBodyParts());
+ assertEquals(request.getVirtualHost(), sent.getVirtualHost());
+ assertSame(request.getProxyServer(), sent.getProxyServer());
+ assertSame(request.getRealm(), sent.getRealm());
+ assertSame(request.getFile(), sent.getFile());
+ assertEquals(request.getFollowRedirect(), sent.getFollowRedirect());
+ assertEquals(request.getUseAbsoluteRequestDeadline(), sent.getUseAbsoluteRequestDeadline());
+ assertEquals(request.getRefuseSchemeDowngradeOnRedirect(), sent.getRefuseSchemeDowngradeOnRedirect());
+ assertEquals(request.getRefuseCrossOriginBodyOnRedirect(), sent.getRefuseCrossOriginBodyOnRedirect());
+ assertEquals(request.getRequestTimeout(), sent.getRequestTimeout());
+ assertEquals(request.getReadTimeout(), sent.getReadTimeout());
+ assertEquals(request.getRangeOffset(), sent.getRangeOffset());
+ assertEquals(request.getCharset(), sent.getCharset());
+ assertSame(request.getChannelPoolPartitioning(), sent.getChannelPoolPartitioning());
+ assertSame(request.getNameResolver(), sent.getNameResolver());
+ assertEquals(request.getQueryParams(), sent.getQueryParams());
+ }
+ }
+
+ @Test
+ public void testStoredCookiesGoThroughToBuilderForRequestSubclass() throws IOException {
+ Request built = get("http://localhost/").addCookie(cookie("a", "request")).build();
+ AtomicInteger toBuilderCalls = new AtomicInteger();
+ Request request = new DefaultRequest(built.getMethod(), built.getUri(), built.getAddress(), built.getLocalAddress(),
+ built.getHeaders(), built.getCookies(), built.getByteData(), built.getCompositeByteData(), built.getStringData(),
+ built.getByteBufferData(), built.getByteBufData(), built.getStreamData(), built.getBodyGenerator(),
+ built.getFormParams(), built.getBodyParts(), built.getVirtualHost(), built.getProxyServer(), built.getRealm(),
+ built.getFile(), built.getFollowRedirect(), built.getRequestTimeout(), built.getReadTimeout(),
+ built.getRangeOffset(), built.getCharset(), built.getChannelPoolPartitioning(), built.getNameResolver()) {
+ @Override
+ public RequestBuilder toBuilder() {
+ toBuilderCalls.incrementAndGet();
+ return super.toBuilder();
+ }
+ };
+
+ Request sent = sendWithStoredCookies(request, cookie("a", "store"), cookie("b", "store"));
+
+ assertEquals(1, toBuilderCalls.get());
+ assertEquals(List.of("a=request", "b=store"), cookieStrings(sent));
+ }
+
+ @Test
+ public void testStoredCookiesAddedToRequestWithNullCookieList() throws IOException {
+ Request built = get("http://localhost/").build();
+ Request request = new DefaultRequest(built.getMethod(), built.getUri(), built.getAddress(), built.getLocalAddress(),
+ built.getHeaders(), null, built.getByteData(), built.getCompositeByteData(), built.getStringData(),
+ built.getByteBufferData(), built.getByteBufData(), built.getStreamData(), built.getBodyGenerator(),
+ built.getFormParams(), built.getBodyParts(), built.getVirtualHost(), built.getProxyServer(), built.getRealm(),
+ built.getFile(), built.getFollowRedirect(), built.getRequestTimeout(), built.getReadTimeout(),
+ built.getRangeOffset(), built.getCharset(), built.getChannelPoolPartitioning(), built.getNameResolver());
+
+ Request sent = sendWithStoredCookies(request, cookie("session", "s"));
+
+ assertEquals(List.of("session=s"), cookieStrings(sent));
+ }
+
+ /** Executes the request on a client whose store holds these cookies for localhost and returns what reached the filters. */
+ private static Request sendWithStoredCookies(Request request, Cookie... storedCookies) throws IOException {
+ CookieStore cookieStore = new ThreadSafeCookieStore();
+ for (Cookie cookie : storedCookies) {
+ cookieStore.add(Uri.create("http://localhost/"), cookie);
+ }
+ AtomicReference sent = new AtomicReference<>();
+ RequestFilter capture = new RequestFilter() {
+ @Override
+ public FilterContext filter(FilterContext ctx) throws FilterException {
+ sent.set(ctx.getRequest());
+ throw new FilterException("captured");
+ }
+ };
+ AsyncHttpClientConfig config = config().setCookieStore(cookieStore).addRequestFilter(capture).build();
+ try (AsyncHttpClient client = asyncHttpClient(config)) {
+ ListenableFuture future = client.executeRequest(request);
+ assertThrows(ExecutionException.class, future::get);
+ }
+ assertNotNull(sent.get());
+ return sent.get();
+ }
+
+ private static Cookie cookie(String name, String value) {
+ DefaultCookie cookie = new DefaultCookie(name, value);
+ cookie.setPath("/");
+ return cookie;
+ }
+
+ private static List cookieStrings(Request request) {
+ return request.getCookies().stream().map(c -> c.name() + '=' + c.value()).collect(Collectors.toList());
+ }
}