diff --git a/docs/modules/ROOT/pages/http-client.adoc b/docs/modules/ROOT/pages/http-client.adoc
index 56e8a9f53..8d6b5b9c0 100644
--- a/docs/modules/ROOT/pages/http-client.adoc
+++ b/docs/modules/ROOT/pages/http-client.adoc
@@ -777,3 +777,128 @@ To customize the default settings, you can configure `HttpClient` as follows:
include::{examples-dir}/resolver/Application.java[lines=18..39]
----
<1> The timeout of each DNS query performed by this resolver will be 500ms.
+
+[[http-authentication]]
+== HTTP Authentication
+Reactor Netty `HttpClient` provides a flexible HTTP authentication framework that allows you to implement
+custom authentication mechanisms such as SPNEGO/Negotiate, OAuth, Bearer tokens, or any other HTTP-based authentication scheme.
+
+The framework provides two APIs for HTTP authentication:
+
+* {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthentication-java.util.function.BiPredicate-java.util.function.BiConsumer-[`httpAuthentication(BiPredicate, BiConsumer)`] -
+For authentication where credentials can be computed immediately without delay. The predicate determines when to retry with authentication. Defaults to 1 maximum retry attempt.
+* {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthentication-java.util.function.BiPredicate-java.util.function.BiConsumer-int-[`httpAuthentication(BiPredicate, BiConsumer, int maxRetries)`] -
+Same as above but allows configuring the maximum count of retry attempts.
+* {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthenticationWhen-java.util.function.BiPredicate-java.util.function.BiFunction-[`httpAuthenticationWhen(BiPredicate, BiFunction)`] -
+For authentication where credentials need to be fetched from external sources or require delayed computation. Returns a `Mono` to support deferred credential retrieval. Defaults to 1 maximum retry attempt.
+* {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthenticationWhen-java.util.function.BiPredicate-java.util.function.BiFunction-int-[`httpAuthenticationWhen(BiPredicate, BiFunction, int maxRetries)`] -
+Same as above but allows configuring the maximum count of retry attempts.
+
+This approach gives you complete control over the authentication flow while Reactor Netty handles the retry mechanism.
+
+=== How It Works
+
+The typical HTTP authentication flow works as follows:
+
+. The client sends an HTTP request to a protected resource.
+. The server responds with an authentication challenge (e.g., `401 Unauthorized` with a `WWW-Authenticate` header).
+. The authenticator function is invoked to add authentication credentials to the request.
+. The request is retried with the authentication credentials.
+. If authentication is successful, the server returns the requested resource.
+
+=== Immediate Authentication with httpAuthentication
+
+For authentication scenarios where credentials can be computed immediately without needing to fetch them from external sources,
+use {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthentication-java.util.function.BiPredicate-java.util.function.BiConsumer-[`httpAuthentication(BiPredicate, BiConsumer)`].
+This method requires both a predicate to determine when to retry and a consumer to add authentication headers.
+
+==== Token-Based Authentication Example
+
+The following example demonstrates how to implement Bearer token authentication with configurable retry count:
+
+{examples-link}/authentication/token/Application.java
+[%unbreakable]
+----
+include::{examples-dir}/authentication/token/Application.java[lines=18..51]
+----
+<1> The predicate checks for `401 Unauthorized` responses to trigger authentication retry.
+<2> The authenticator adds the `Authorization` header with a Bearer token.
+<3> Configures the maximum count of authentication retry attempts to 3.
+
+==== Basic Authentication Example
+
+{examples-link}/authentication/basic/Application.java
+[%unbreakable]
+----
+include::{examples-dir}/authentication/basic/Application.java[lines=18..44]
+----
+<1> The predicate checks for `401 Unauthorized` responses to trigger authentication retry.
+<2> The authenticator adds Basic authentication credentials to the `Authorization` header.
+<3> Uses the default maximum retry count of 1.
+
+=== Custom Authentication with httpAuthenticationWhen
+
+When you need custom retry conditions (e.g., checking specific headers or status codes other than 401),
+use the {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthenticationWhen-java.util.function.BiPredicate-java.util.function.BiFunction-[`httpAuthenticationWhen(BiPredicate, BiFunction)`] method.
+
+==== SPNEGO/Negotiate Authentication Example
+
+For SPNEGO (Kerberos) authentication, you can implement a custom authenticator using Java's GSS-API:
+
+{examples-link}/authentication/spnego/Application.java
+[%unbreakable]
+----
+include::{examples-dir}/authentication/spnego/Application.java[lines=18..70]
+----
+<1> Custom predicate checks for `401 Unauthorized` with `WWW-Authenticate: Negotiate` header.
+<2> The authenticator generates a SPNEGO token using GSS-API and adds it to the `Authorization` header.
+<3> Configures the maximum count of authentication retry attempts to 2.
+
+NOTE: For SPNEGO authentication, you need to configure Kerberos settings (e.g., `krb5.conf`) and JAAS configuration
+(e.g., `jaas.conf`) appropriately. Set the system properties `java.security.krb5.conf` and `java.security.auth.login.config`
+to point to your configuration files.
+
+=== Custom Authentication Scenarios
+
+The authentication framework is flexible enough to support various authentication scenarios:
+
+==== OAuth 2.0 Authentication
+[source,java]
+----
+HttpClient client = HttpClient.create()
+ .httpAuthenticationWhen(
+ (req, res) -> res.status().code() == 401, // <1>
+ (req, addr) -> {
+ return fetchOAuthToken() // <2>
+ .doOnNext(token ->
+ req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token))
+ .then();
+ }
+ );
+----
+<1> Retry authentication on `401 Unauthorized` responses.
+<2> Asynchronously fetch an OAuth token and add it to the request.
+
+==== Proxy Authentication
+[source,java]
+----
+HttpClient client = HttpClient.create()
+ .httpAuthenticationWhen(
+ (req, res) -> res.status().code() == 407, // <1>
+ (req, addr) -> {
+ String proxyCredentials = generateProxyCredentials();
+ req.header("Proxy-Authorization", "Bearer " + proxyCredentials);
+ return Mono.empty();
+ }
+ );
+----
+<1> Custom predicate checks for `407 Proxy Authentication Required` status code.
+
+=== Important Notes
+
+* The authenticator function is invoked only when the predicate returns `true` (indicating authentication is needed).
+* For `httpAuthentication`, use a `BiConsumer` when credentials can be computed immediately without delay. No `Mono` is needed as headers are added directly.
+* For `httpAuthenticationWhen`, use a `BiFunction` that returns `Mono` when credentials need to be fetched from external sources or require delayed computation.
+* The authenticator receives the request and remote address, allowing you to customize authentication based on the target server.
+* By default, authentication is retried once per request. You can configure the maximum count of retry attempts using the `maxRetries` parameter. If authentication fails after all retry attempts are exhausted, the error is propagated to the caller.
+* For security reasons, ensure that sensitive credentials are not logged or exposed in error messages.
diff --git a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/basic/Application.java b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/basic/Application.java
new file mode 100644
index 000000000..c7e114f39
--- /dev/null
+++ b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/basic/Application.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2025 VMware, Inc. or its affiliates, 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
+ *
+ * https://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 reactor.netty.examples.documentation.http.client.authentication.basic;
+
+import io.netty.handler.codec.http.HttpHeaderNames;
+import reactor.netty.http.client.HttpClient;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+public class Application {
+
+ public static void main(String[] args) {
+ HttpClient client =
+ HttpClient.create()
+ .httpAuthentication(
+ (req, res) -> res.status().code() == 401, // <1>
+ (req, addr) -> { // <2>
+ String credentials = "username:password";
+ String encodedCredentials = Base64.getEncoder()
+ .encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
+ req.header(HttpHeaderNames.AUTHORIZATION, "Basic " + encodedCredentials);
+ } // <3>
+ );
+
+ client.get()
+ .uri("https://example.com/")
+ .response()
+ .block();
+ }
+}
\ No newline at end of file
diff --git a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/spnego/Application.java b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/spnego/Application.java
new file mode 100644
index 000000000..83709b5eb
--- /dev/null
+++ b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/spnego/Application.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2025 VMware, Inc. or its affiliates, 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
+ *
+ * https://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 reactor.netty.examples.documentation.http.client.authentication.spnego;
+
+import io.netty.handler.codec.http.HttpHeaderNames;
+import org.ietf.jgss.GSSContext;
+import org.ietf.jgss.GSSException;
+import org.ietf.jgss.GSSManager;
+import org.ietf.jgss.GSSName;
+import org.ietf.jgss.Oid;
+import reactor.core.publisher.Mono;
+import reactor.netty.http.client.HttpClient;
+
+import java.net.InetSocketAddress;
+import java.util.Base64;
+
+public class Application {
+
+ public static void main(String[] args) {
+ HttpClient client =
+ HttpClient.create()
+ .httpAuthenticationWhen(
+ (req, res) -> res.status().code() == 401 && // <1>
+ res.responseHeaders().contains("WWW-Authenticate", "Negotiate", true),
+ (req, addr) -> { // <2>
+ try {
+ GSSManager manager = GSSManager.getInstance();
+ String hostName = ((InetSocketAddress) addr).getHostString();
+ String serviceName = "HTTP@" + hostName;
+ GSSName serverName = manager.createName(serviceName, GSSName.NT_HOSTBASED_SERVICE);
+
+ Oid krb5Mechanism = new Oid("1.2.840.113554.1.2.2");
+ GSSContext context = manager.createContext(
+ serverName, krb5Mechanism, null, GSSContext.DEFAULT_LIFETIME);
+
+ byte[] token = context.initSecContext(new byte[0], 0, 0);
+ String encodedToken = Base64.getEncoder().encodeToString(token);
+
+ req.header(HttpHeaderNames.AUTHORIZATION, "Negotiate " + encodedToken);
+
+ context.dispose();
+ }
+ catch (GSSException e) {
+ return Mono.error(new RuntimeException(
+ "Failed to generate SPNEGO token", e));
+ }
+ return Mono.empty();
+ },
+ 2 // <3>
+ );
+
+ client.get()
+ .uri("https://example.com/")
+ .response()
+ .block();
+ }
+}
\ No newline at end of file
diff --git a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/token/Application.java b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/token/Application.java
new file mode 100644
index 000000000..578091e22
--- /dev/null
+++ b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/token/Application.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2025 VMware, Inc. or its affiliates, 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
+ *
+ * https://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 reactor.netty.examples.documentation.http.client.authentication.token;
+
+import io.netty.handler.codec.http.HttpHeaderNames;
+import reactor.netty.http.client.HttpClient;
+
+import java.net.SocketAddress;
+
+public class Application {
+
+ public static void main(String[] args) {
+ HttpClient client =
+ HttpClient.create()
+ .httpAuthentication(
+ (req, res) -> res.status().code() == 401, // <1>
+ (req, addr) -> { // <2>
+ String token = generateAuthToken(addr);
+ req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token);
+ },
+ 3 // <3>
+ );
+
+ client.get()
+ .uri("https://example.com/")
+ .response()
+ .block();
+ }
+
+ /**
+ * Generates an authentication token for the given remote address.
+ * In a real application, this would retrieve or generate a valid token.
+ */
+ static String generateAuthToken(SocketAddress remoteAddress) {
+ // In a real application, implement token generation/retrieval logic
+ return "sample-token-123";
+ }
+}
\ No newline at end of file
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/Http2WebsocketClientOperations.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/Http2WebsocketClientOperations.java
index d4c055253..c5e8dddd5 100644
--- a/reactor-netty-http/src/main/java/reactor/netty/http/client/Http2WebsocketClientOperations.java
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/Http2WebsocketClientOperations.java
@@ -111,7 +111,7 @@ else if (msg instanceof HttpResponse) {
setNettyResponse(response);
- if (notRedirected(response)) {
+ if (notRedirected(response) && authenticationNotRequired()) {
try {
HttpResponseStatus status = response.status();
if (!HttpResponseStatus.OK.equals(status)) {
@@ -131,9 +131,12 @@ else if (msg instanceof HttpResponse) {
}
}
else {
- // Deliberately suppress "NullAway"
- // redirecting != null in this case
- listener().onUncaughtException(this, redirecting);
+ if (redirecting != null) {
+ listener().onUncaughtException(this, redirecting);
+ }
+ else if (authenticating != null) {
+ listener().onUncaughtException(this, authenticating);
+ }
}
}
else {
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClient.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClient.java
index 4ebc6f6c7..eaab01796 100644
--- a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClient.java
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClient.java
@@ -110,6 +110,7 @@
*
* @author Stephane Maldini
* @author Violeta Georgieva
+ * @author raccoonback
*/
public abstract class HttpClient extends ClientTransport {
@@ -1696,6 +1697,230 @@ public final HttpClient wiretap(boolean enable) {
return super.wiretap(enable);
}
+ /**
+ * Enables a mechanism for an automatic retry of the requests when HTTP authentication is expected:
+ *
+ *
+ * - The initial request is sent without authentication
+ * - If the response matches the {@code predicate} (e.g., 401 Unauthorized), the request is retried
+ * - Before retry, the {@code authenticator} adds credentials to the request
+ *
+ *
+ *
+ * This method defaults to 1 maximum retry attempt. To configure a different maximum retry count,
+ * use {@link #httpAuthentication(BiPredicate, BiConsumer, int)} instead.
+ *
+ *
+ * Use this method when authentication credentials can be computed synchronously (without I/O operations).
+ * For async credential retrieval (e.g., fetching tokens from external services), use
+ * {@link #httpAuthenticationWhen(BiPredicate, BiFunction)} instead.
+ *
+ *
+ * Example - Bearer token authentication with custom retry logic:
+ *
+ * {@code
+ * HttpClient client = HttpClient.create()
+ * .httpAuthentication(
+ * // Retry on 401 Unauthorized
+ * (req, res) -> res.status().code() == 401,
+ * // Add authentication header before retry
+ * (req, addr) -> {
+ * String token = generateAuthToken(addr);
+ * req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token);
+ * }
+ * );
+ * }
+ *
+ *
+ * @param predicate determines whether to retry with authentication; receives the original request
+ * and the response from the failed attempt (e.g., check for 401 Unauthorized status)
+ * @param authenticator applies authentication credentials to the request before retry; receives
+ * the request and remote address
+ * @return a new {@link HttpClient}
+ * @since 1.3.1
+ * @see #httpAuthentication(BiPredicate, BiConsumer, int)
+ * @see #httpAuthenticationWhen(BiPredicate, BiFunction)
+ */
+ public final HttpClient httpAuthentication(
+ BiPredicate super HttpClientRequest, ? super HttpClientResponse> predicate,
+ BiConsumer super HttpClientRequest, ? super SocketAddress> authenticator) {
+ return httpAuthentication(predicate, authenticator, 1);
+ }
+
+ /**
+ * Enables a mechanism for an automatic retry of the requests when HTTP authentication is expected,
+ * with configurable maximum retry attempts:
+ *
+ *
+ * - The initial request is sent without authentication
+ * - If the response matches the {@code predicate} (e.g., 401 Unauthorized), the request is retried
+ * - Before retry, the {@code authenticator} adds credentials to the request
+ * - Retries continue until authentication succeeds or {@code maxRetries} is reached
+ *
+ *
+ *
+ * Use this method when authentication credentials can be computed synchronously (without I/O operations).
+ * For async credential retrieval (e.g., fetching tokens from external services), use
+ * {@link #httpAuthenticationWhen(BiPredicate, BiFunction, int)} instead.
+ *
+ *
+ * Example - Bearer token authentication with custom retry logic and maximum 2 retries:
+ *
+ * {@code
+ * HttpClient client = HttpClient.create()
+ * .httpAuthentication(
+ * // Retry on 401 Unauthorized
+ * (req, res) -> res.status().code() == 401,
+ * // Add authentication header before retry
+ * (req, addr) -> {
+ * String token = generateAuthToken(addr);
+ * req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token);
+ * },
+ * 2 // Maximum retry attempts
+ * );
+ * }
+ *
+ *
+ * @param predicate determines whether to retry with authentication; receives the original request
+ * and the response from the failed attempt (e.g., check for 401 Unauthorized status)
+ * @param authenticator applies authentication credentials to the request before retry; receives
+ * the request and remote address
+ * @param maxRetries the maximum number of retry attempts for authentication (must be positive)
+ * @return a new {@link HttpClient}
+ * @throws IllegalArgumentException if {@code maxRetries} is less than 1
+ * @since 1.3.1
+ * @see #httpAuthenticationWhen(BiPredicate, BiFunction, int)
+ */
+ public final HttpClient httpAuthentication(
+ BiPredicate super HttpClientRequest, ? super HttpClientResponse> predicate,
+ BiConsumer super HttpClientRequest, ? super SocketAddress> authenticator,
+ int maxRetries
+ ) {
+ return httpAuthenticationWhen(
+ predicate,
+ (req, addr) -> {
+ authenticator.accept(req, addr);
+ return Mono.empty();
+ },
+ maxRetries
+ );
+ }
+
+ /**
+ * Enables a mechanism for an automatic retry of the requests when HTTP authentication is expected,
+ * with support for async credential retrieval:
+ *
+ *
+ * - The initial request is sent without authentication
+ * - If the response matches the {@code predicate} (e.g., 401 Unauthorized), the request is retried
+ * - Before retry, the {@code authenticator} asynchronously adds credentials to the request
+ *
+ *
+ *
+ * This method defaults to 1 maximum retry attempt. To configure a different maximum retry count,
+ * use {@link #httpAuthenticationWhen(BiPredicate, BiFunction, int)} instead.
+ *
+ *
+ * Use this method when authentication credentials require async operations (e.g., fetching tokens
+ * from external services, reading from databases, or performing I/O). For synchronous credential
+ * computation, {@link #httpAuthentication(BiPredicate, BiConsumer)} provides a simpler API.
+ *
+ *
+ * Example - Async token retrieval with custom retry logic:
+ *
+ * {@code
+ * HttpClient client = HttpClient.create()
+ * .httpAuthenticationWhen(
+ * // Retry on 401 Unauthorized
+ * (req, res) -> res.status().code() == 401,
+ * // Fetch token asynchronously and add authentication header before retry
+ * (req, addr) -> tokenService.fetchToken(addr)
+ * .doOnNext(token ->
+ * req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token))
+ * .then()
+ * );
+ * }
+ *
+ *
+ * @param predicate determines whether to retry with authentication; receives the original request
+ * and the response from the failed attempt (e.g., check for 401 Unauthorized status)
+ * @param authenticator applies authentication credentials to the request before retry; receives
+ * the request and remote address, returns a {@link Mono} that completes when
+ * authentication credentials have been applied
+ * @return a new {@link HttpClient}
+ * @since 1.3.1
+ * @see #httpAuthenticationWhen(BiPredicate, BiFunction, int)
+ * @see #httpAuthentication(BiPredicate, BiConsumer)
+ */
+ public final HttpClient httpAuthenticationWhen(
+ BiPredicate super HttpClientRequest, ? super HttpClientResponse> predicate,
+ BiFunction super HttpClientRequest, ? super SocketAddress, ? extends Mono> authenticator) {
+ return httpAuthenticationWhen(predicate, authenticator, 1);
+ }
+
+ /**
+ * Enables a mechanism for an automatic retry of the requests when HTTP authentication is expected,
+ * with support for async credential retrieval and configurable maximum retry attempts:
+ *
+ *
+ * - The initial request is sent without authentication
+ * - If the response matches the {@code predicate} (e.g., 401 Unauthorized), the request is retried
+ * - Before retry, the {@code authenticator} asynchronously adds credentials to the request
+ * - Retries continue until authentication succeeds or {@code maxRetries} is reached
+ *
+ *
+ *
+ * Use this method when authentication credentials require async operations (e.g., fetching tokens
+ * from external services, reading from databases, or performing I/O). For synchronous credential
+ * computation, {@link #httpAuthentication(BiPredicate, BiConsumer, int)} provides a simpler API.
+ *
+ *
+ * Example - Async token retrieval with custom retry logic and maximum 2 retries:
+ *
+ * {@code
+ * HttpClient client = HttpClient.create()
+ * .httpAuthenticationWhen(
+ * // Retry on 401 Unauthorized
+ * (req, res) -> res.status().code() == 401,
+ * // Fetch token asynchronously and add authentication header before retry
+ * (req, addr) -> tokenService.fetchToken(addr)
+ * .doOnNext(token ->
+ * req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token))
+ * .then(),
+ * 2 // Maximum retry attempts
+ * );
+ * }
+ *
+ *
+ * @param predicate determines whether to retry with authentication; receives the original request
+ * and the response from the failed attempt (e.g., check for 401 Unauthorized status)
+ * @param authenticator applies authentication credentials to the request before retry; receives
+ * the request and remote address, returns a {@link Mono} that completes when
+ * authentication credentials have been applied
+ * @param maxRetries the maximum number of retry attempts for authentication (must be positive)
+ * @return a new {@link HttpClient}
+ * @throws IllegalArgumentException if {@code maxRetries} is less than 1
+ * @since 1.3.1
+ * @see #httpAuthentication(BiPredicate, BiConsumer, int)
+ */
+ public final HttpClient httpAuthenticationWhen(
+ BiPredicate super HttpClientRequest, ? super HttpClientResponse> predicate,
+ BiFunction super HttpClientRequest, ? super SocketAddress, ? extends Mono> authenticator,
+ int maxRetries
+ ) {
+ Objects.requireNonNull(predicate, "predicate");
+ Objects.requireNonNull(authenticator, "authenticator");
+ if (maxRetries < 1) {
+ throw new IllegalArgumentException("maxRetries must be at least 1, was: " + maxRetries);
+ }
+
+ HttpClient dup = duplicate();
+ dup.configuration().authenticationPredicate = predicate;
+ dup.configuration().authenticator = authenticator;
+ dup.configuration().maxAuthenticationRetries = maxRetries;
+ return dup;
+ }
+
static boolean isCompressing(HttpHeaders h) {
return h.contains(HttpHeaderNames.ACCEPT_ENCODING, HttpHeaderValues.GZIP, true)
|| h.contains(HttpHeaderNames.ACCEPT_ENCODING, HttpHeaderValues.BR, true);
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientAuthenticationException.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientAuthenticationException.java
new file mode 100644
index 000000000..581eb3e11
--- /dev/null
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientAuthenticationException.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2025 VMware, Inc. or its affiliates, 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
+ *
+ * https://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 reactor.netty.http.client;
+
+/**
+ * This exception is used internally to signal that the current request requires HTTP authentication and should be retried.
+ * The {@code authenticator} configured via {@link HttpClient#httpAuthentication(java.util.function.BiPredicate, java.util.function.BiConsumer)}
+ * or {@link HttpClient#httpAuthenticationWhen(java.util.function.BiPredicate, java.util.function.BiFunction)}
+ * will be invoked before retrying the request.
+ *
+ * @author raccoonback
+ * @since 1.3.1
+ * @see HttpClient#httpAuthentication(java.util.function.BiPredicate, java.util.function.BiConsumer)
+ * @see HttpClient#httpAuthenticationWhen(java.util.function.BiPredicate, java.util.function.BiFunction)
+ */
+final class HttpClientAuthenticationException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public synchronized Throwable fillInStackTrace() {
+ // omit stacktrace for this exception
+ return this;
+ }
+
+ HttpClientAuthenticationException() {
+ super("HTTP authentication required, triggering retry");
+ }
+}
\ No newline at end of file
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConfig.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConfig.java
index df3446b6b..adf151eb1 100644
--- a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConfig.java
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConfig.java
@@ -108,6 +108,7 @@
*
* @author Stephane Maldini
* @author Violeta Georgieva
+ * @author raccoonback
*/
public final class HttpClientConfig extends ClientTransportConfig {
@@ -367,6 +368,9 @@ public HttpProtocol[] protocols() {
@Nullable String uriStr;
@Nullable Function uriTagValue;
@Nullable WebsocketClientSpec websocketClientSpec;
+ @Nullable BiPredicate super HttpClientRequest, ? super HttpClientResponse> authenticationPredicate;
+ @Nullable BiFunction super HttpClientRequest, ? super SocketAddress, ? extends Mono> authenticator;
+ int maxAuthenticationRetries;
HttpClientConfig(HttpConnectionProvider connectionProvider, Map, ?> options,
Supplier extends SocketAddress> remoteAddress) {
@@ -419,6 +423,9 @@ public HttpProtocol[] protocols() {
this.uriStr = parent.uriStr;
this.uriTagValue = parent.uriTagValue;
this.websocketClientSpec = parent.websocketClientSpec;
+ this.authenticationPredicate = parent.authenticationPredicate;
+ this.authenticator = parent.authenticator;
+ this.maxAuthenticationRetries = parent.maxAuthenticationRetries;
}
@Override
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java
index fb2649575..367594d99 100644
--- a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java
@@ -75,6 +75,7 @@
*
* @author Stephane Maldini
* @author Violeta Georgieva
+ * @author raccoonback
*/
class HttpClientConnect extends HttpClient {
@@ -498,6 +499,11 @@ static final class HttpClientHandler extends SocketAddress
volatile Supplier @Nullable [] redirectedFrom;
volatile boolean shouldRetry;
volatile @Nullable HttpHeaders previousRequestHeaders;
+ volatile int authenticationRetries;
+
+ @Nullable BiPredicate super HttpClientRequest, ? super HttpClientResponse> authenticationPredicate;
+ @Nullable BiFunction super HttpClientRequest, ? super SocketAddress, ? extends Mono> authenticator;
+ int maxAuthenticationRetries;
HttpClientHandler(HttpClientConfig configuration) {
this.method = configuration.method;
@@ -536,6 +542,9 @@ static final class HttpClientHandler extends SocketAddress
this.fromURI = this.toURI = uriEndpointFactory.createUriEndpoint(configuration.uri, configuration.websocketClientSpec != null);
}
this.resourceUrl = toURI.toExternalForm();
+ this.authenticationPredicate = configuration.authenticationPredicate;
+ this.authenticator = configuration.authenticator;
+ this.maxAuthenticationRetries = configuration.maxAuthenticationRetries;
}
@Override
@@ -582,6 +591,7 @@ Publisher requestWithBody(HttpClientOperations ch) {
}
ch.followRedirectPredicate(followRedirectPredicate);
+ ch.authenticationPredicate(authenticationPredicate);
if (!Objects.equals(method, HttpMethod.GET) &&
!Objects.equals(method, HttpMethod.HEAD) &&
@@ -591,6 +601,9 @@ Publisher requestWithBody(HttpClientOperations ch) {
}
ch.listener().onStateChange(ch, HttpClientState.REQUEST_PREPARED);
+
+ Publisher result;
+
if (websocketClientSpec != null) {
// ReferenceEquality is deliberate
if (ch.version == H2) {
@@ -604,48 +617,57 @@ Publisher requestWithBody(HttpClientOperations ch) {
"[SETTINGS_ENABLE_CONNECT_PROTOCOL(0x8)=0] was received.");
}
}
- Mono result =
+ Mono wsResult =
Mono.fromRunnable(() -> ch.withWebsocketSupport(websocketClientSpec));
if (handler != null) {
- result = result.thenEmpty(Mono.fromRunnable(() -> Flux.concat(handler.apply(ch, ch))));
+ wsResult = wsResult.thenEmpty(Mono.fromRunnable(() -> Flux.concat(handler.apply(ch, ch))));
}
- return result;
+ result = wsResult;
}
+ else {
+ Consumer consumer = null;
+ if (fromURI != null && !toURI.equals(fromURI)) {
+ if (handler instanceof RedirectSendHandler) {
+ headers.remove(HttpHeaderNames.EXPECT)
+ .remove(HttpHeaderNames.COOKIE)
+ .remove(HttpHeaderNames.AUTHORIZATION)
+ .remove(HttpHeaderNames.PROXY_AUTHORIZATION);
+ }
+ else {
+ consumer = request ->
+ request.requestHeaders()
+ .remove(HttpHeaderNames.EXPECT)
+ .remove(HttpHeaderNames.COOKIE)
+ .remove(HttpHeaderNames.AUTHORIZATION)
+ .remove(HttpHeaderNames.PROXY_AUTHORIZATION);
+ }
+ }
+ if (this.redirectRequestConsumer != null) {
+ consumer = consumer != null ? consumer.andThen(this.redirectRequestConsumer) : this.redirectRequestConsumer;
+ }
- Consumer consumer = null;
- if (fromURI != null && !toURI.equals(fromURI)) {
- if (handler instanceof RedirectSendHandler) {
- headers.remove(HttpHeaderNames.EXPECT)
- .remove(HttpHeaderNames.COOKIE)
- .remove(HttpHeaderNames.AUTHORIZATION)
- .remove(HttpHeaderNames.PROXY_AUTHORIZATION);
+ if (redirectRequestBiConsumer != null) {
+ ch.previousRequestHeaders = previousRequestHeaders;
+ ch.redirectRequestBiConsumer = redirectRequestBiConsumer;
+ }
+
+ ch.redirectRequestConsumer(consumer);
+ if (handler != null) {
+ Publisher publisher = handler.apply(ch, ch);
+ result = ch.equals(publisher) ? ch.send() : publisher;
}
else {
- consumer = request ->
- request.requestHeaders()
- .remove(HttpHeaderNames.EXPECT)
- .remove(HttpHeaderNames.COOKIE)
- .remove(HttpHeaderNames.AUTHORIZATION)
- .remove(HttpHeaderNames.PROXY_AUTHORIZATION);
+ result = ch.send();
}
}
- if (this.redirectRequestConsumer != null) {
- consumer = consumer != null ? consumer.andThen(this.redirectRequestConsumer) : this.redirectRequestConsumer;
- }
- if (redirectRequestBiConsumer != null) {
- ch.previousRequestHeaders = previousRequestHeaders;
- ch.redirectRequestBiConsumer = redirectRequestBiConsumer;
+ // Apply authenticator if needed (after REQUEST_PREPARED)
+ if (authenticator != null && authenticationRetries > 0) {
+ return authenticator.apply(ch, ch.address())
+ .then(Mono.defer(() -> Mono.from(result)));
}
- ch.redirectRequestConsumer(consumer);
- if (handler != null) {
- Publisher publisher = handler.apply(ch, ch);
- return ch.equals(publisher) ? ch.send() : publisher;
- }
- else {
- return ch.send();
- }
+ return result;
}
catch (Throwable t) {
return Mono.error(t);
@@ -716,6 +738,7 @@ void channel(HttpClientOperations ops) {
if (redirectedFrom != null) {
ops.redirectedFrom = redirectedFrom;
}
+ ops.configureAuthenticationRetries(this.authenticationRetries, this.maxAuthenticationRetries);
}
@Override
@@ -728,6 +751,15 @@ public boolean test(Throwable throwable) {
redirect(re.location);
return true;
}
+ if (throwable instanceof HttpClientAuthenticationException) {
+ // Check if we've exceeded the max retry limit
+ if (authenticationRetries >= maxAuthenticationRetries) {
+ return false;
+ }
+ // Increment retry counter to trigger authenticator on retry
+ authenticationRetries++;
+ return true;
+ }
if (shouldRetry && AbortedException.isConnectionReset(throwable)) {
shouldRetry = false;
redirect(toURI.toString());
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientOperations.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientOperations.java
index 2eb1177b6..72e7388e2 100644
--- a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientOperations.java
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientOperations.java
@@ -104,6 +104,7 @@
*
* @author Stephane Maldini
* @author Simon Baslé
+ * @author raccoonback
*/
class HttpClientOperations extends HttpOperations
implements HttpClientResponse, HttpClientRequest {
@@ -118,6 +119,8 @@ class HttpClientOperations extends HttpOperations
final HttpVersion version;
Supplier[] redirectedFrom = EMPTY_REDIRECTIONS;
+ int authenticationRetries;
+ int maxAuthenticationRetries;
@Nullable String resourceUrl;
@Nullable String path;
@Nullable Duration responseTimeout;
@@ -128,8 +131,10 @@ class HttpClientOperations extends HttpOperations
boolean retrying;
boolean is100Continue;
@Nullable RedirectClientException redirecting;
+ @Nullable HttpClientAuthenticationException authenticating;
@Nullable BiPredicate followRedirectPredicate;
+ @Nullable BiPredicate super HttpClientRequest, ? super HttpClientResponse> authenticationPredicate;
@Nullable Consumer redirectRequestConsumer;
@Nullable HttpHeaders previousRequestHeaders;
@Nullable BiConsumer redirectRequestBiConsumer;
@@ -142,7 +147,10 @@ class HttpClientOperations extends HttpOperations
this.started = replaced.started;
this.retrying = replaced.retrying;
this.redirecting = replaced.redirecting;
+ this.authenticating = replaced.authenticating;
this.redirectedFrom = replaced.redirectedFrom;
+ this.authenticationRetries = replaced.authenticationRetries;
+ this.maxAuthenticationRetries = replaced.maxAuthenticationRetries;
this.redirectRequestConsumer = replaced.redirectRequestConsumer;
this.previousRequestHeaders = replaced.previousRequestHeaders;
this.redirectRequestBiConsumer = replaced.redirectRequestBiConsumer;
@@ -150,6 +158,7 @@ class HttpClientOperations extends HttpOperations
this.nettyRequest = replaced.nettyRequest;
this.responseState = replaced.responseState;
this.followRedirectPredicate = replaced.followRedirectPredicate;
+ this.authenticationPredicate = replaced.authenticationPredicate;
this.requestHeaders = replaced.requestHeaders;
this.cookieEncoder = replaced.cookieEncoder;
this.cookieDecoder = replaced.cookieDecoder;
@@ -171,7 +180,10 @@ class HttpClientOperations extends HttpOperations
this.started = replaced.started;
this.retrying = replaced.retrying;
this.redirecting = replaced.redirecting;
+ this.authenticating = replaced.authenticating;
this.redirectedFrom = replaced.redirectedFrom;
+ this.authenticationRetries = replaced.authenticationRetries;
+ this.maxAuthenticationRetries = replaced.maxAuthenticationRetries;
this.redirectRequestConsumer = replaced.redirectRequestConsumer;
this.previousRequestHeaders = replaced.previousRequestHeaders;
this.redirectRequestBiConsumer = replaced.redirectRequestBiConsumer;
@@ -179,6 +191,7 @@ class HttpClientOperations extends HttpOperations
this.nettyRequest = replaced.nettyRequest;
this.responseState = replaced.responseState;
this.followRedirectPredicate = replaced.followRedirectPredicate;
+ this.authenticationPredicate = replaced.authenticationPredicate;
this.requestHeaders = replaced.requestHeaders;
this.cookieEncoder = replaced.cookieEncoder;
this.cookieDecoder = replaced.cookieDecoder;
@@ -339,6 +352,15 @@ void followRedirectPredicate(@Nullable BiPredicate predicate) {
+ this.authenticationPredicate = predicate;
+ }
+
+ void configureAuthenticationRetries(int authenticationRetries, int maxAuthenticationRetries) {
+ this.authenticationRetries = authenticationRetries;
+ this.maxAuthenticationRetries = maxAuthenticationRetries;
+ }
+
void redirectRequestConsumer(@Nullable Consumer redirectRequestConsumer) {
this.redirectRequestConsumer = redirectRequestConsumer;
}
@@ -401,6 +423,9 @@ protected void afterInboundComplete() {
if (redirecting != null) {
listener().onUncaughtException(this, redirecting);
}
+ else if (authenticating != null) {
+ listener().onUncaughtException(this, authenticating);
+ }
else {
listener().onStateChange(this, HttpClientState.RESPONSE_COMPLETED);
}
@@ -837,7 +862,7 @@ protected void onInboundNext(ChannelHandlerContext ctx, Object msg) {
httpMessageLogFactory().debug(HttpMessageArgProviderFactory.create(response)));
}
- if (notRedirected(response)) {
+ if (notRedirected(response) && authenticationNotRequired()) {
try {
listener().onStateChange(this, HttpClientState.RESPONSE_RECEIVED);
}
@@ -848,7 +873,7 @@ protected void onInboundNext(ChannelHandlerContext ctx, Object msg) {
}
}
else {
- // when redirecting no need of manual reading
+ // when redirecting or authenticating no need of manual reading
channel().config().setAutoRead(true);
}
@@ -903,7 +928,7 @@ protected void onInboundNext(ChannelHandlerContext ctx, Object msg) {
if (lastHttpContent != LastHttpContent.EMPTY_LAST_CONTENT) {
// When there is HTTP/2 response with INBOUND HEADERS(endStream=false) followed by INBOUND DATA(endStream=true length=0),
// Netty sends LastHttpContent with empty buffer instead of EMPTY_LAST_CONTENT
- if (redirecting != null || lastHttpContent.content().readableBytes() == 0) {
+ if (redirecting != null || authenticating != null || lastHttpContent.content().readableBytes() == 0) {
lastHttpContent.release();
}
else {
@@ -911,7 +936,7 @@ protected void onInboundNext(ChannelHandlerContext ctx, Object msg) {
}
}
- if (redirecting == null) {
+ if (redirecting == null && authenticating == null) {
// EmitResult is ignored as it is guaranteed that there will be only one emission of LastHttpContent
// Whether there are subscribers or the subscriber cancels is not of interest
// Evaluated EmitResult: FAIL_TERMINATED, FAIL_OVERFLOW, FAIL_CANCELLED, FAIL_NON_SERIALIZED
@@ -941,9 +966,9 @@ protected void onInboundNext(ChannelHandlerContext ctx, Object msg) {
return;
}
- if (redirecting != null) {
+ if (redirecting != null || authenticating != null) {
ReferenceCountUtil.release(msg);
- // when redirecting auto-read is set to true, no need of manual reading
+ // when redirecting or authenticating auto-read is set to true, no need of manual reading
return;
}
super.onInboundNext(ctx, msg);
@@ -977,6 +1002,19 @@ final boolean notRedirected(HttpResponse response) {
return true;
}
+ final boolean authenticationNotRequired() {
+ if (authenticationPredicate != null && authenticationRetries < maxAuthenticationRetries &&
+ authenticationPredicate.test(this, this)) {
+ authenticating = new HttpClientAuthenticationException();
+ if (log.isDebugEnabled()) {
+ log.debug(format(channel(), "Authentication predicate matched, triggering retry (attempt {} of {})"),
+ authenticationRetries + 1, maxAuthenticationRetries);
+ }
+ return false;
+ }
+ return true;
+ }
+
@Override
protected HttpMessage newFullBodyMessage(ByteBuf body) {
HttpRequest request = new DefaultFullHttpRequest(version(), method(), uri(), body);
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/WebsocketClientOperations.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/WebsocketClientOperations.java
index 5a5b12d38..92c4f2a58 100644
--- a/reactor-netty-http/src/main/java/reactor/netty/http/client/WebsocketClientOperations.java
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/WebsocketClientOperations.java
@@ -59,6 +59,7 @@
*
* @author Stephane Maldini
* @author Simon Baslé
+ * @author raccoonback
*/
class WebsocketClientOperations extends HttpClientOperations
implements WebsocketInbound, WebsocketOutbound {
@@ -144,7 +145,7 @@ public void onInboundNext(ChannelHandlerContext ctx, Object msg) {
setNettyResponse(response);
- if (notRedirected(response)) {
+ if (notRedirected(response) && authenticationNotRequired()) {
try {
handshakerHttp11.finishHandshake(channel(), response);
// This change is needed after the Netty change https://github.com/netty/netty/pull/11966
@@ -165,9 +166,12 @@ public void onInboundNext(ChannelHandlerContext ctx, Object msg) {
else {
response.content()
.release();
- // Deliberately suppress "NullAway"
- // redirecting is initialized in notRedirected(response)
- listener().onUncaughtException(this, redirecting);
+ if (redirecting != null) {
+ listener().onUncaughtException(this, redirecting);
+ }
+ else if (authenticating != null) {
+ listener().onUncaughtException(this, authenticating);
+ }
}
return;
}
diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientOperationsTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientOperationsTest.java
index 67d15cecc..ae082fb45 100644
--- a/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientOperationsTest.java
+++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientOperationsTest.java
@@ -197,6 +197,7 @@ void testConstructorWithProvidedReplacement_1() {
ops1.retrying = true;
ops1.redirecting = new RedirectClientException(new DefaultHttpHeaders().add(HttpHeaderNames.LOCATION, "/"),
HttpResponseStatus.MOVED_PERMANENTLY);
+ ops1.authenticating = new HttpClientAuthenticationException();
HttpClientOperations ops2 = new HttpClientOperations(ops1);
@@ -204,6 +205,7 @@ void testConstructorWithProvidedReplacement_1() {
assertThat(ops1.started).isSameAs(ops2.started);
assertThat(ops1.retrying).isSameAs(ops2.retrying);
assertThat(ops1.redirecting).isSameAs(ops2.redirecting);
+ assertThat(ops1.authenticating).isSameAs(ops2.authenticating);
assertThat(ops1.redirectedFrom).isSameAs(ops2.redirectedFrom);
assertThat(ops1.isSecure).isSameAs(ops2.isSecure);
assertThat(ops1.nettyRequest).isSameAs(ops2.nettyRequest);
@@ -535,6 +537,7 @@ private static void checkRequest(HttpClientRequest request, HttpClientResponse r
assertThat(req.isSecure).isSameAs(res.isSecure);
assertThat(req.nettyRequest).isSameAs(res.nettyRequest);
assertThat(req.followRedirectPredicate).isSameAs(res.followRedirectPredicate);
+ assertThat(req.authenticationPredicate).isSameAs(res.authenticationPredicate);
assertThat(req.requestHeaders).isSameAs(res.requestHeaders);
assertThat(req.cookieEncoder).isSameAs(res.cookieEncoder);
assertThat(req.cookieDecoder).isSameAs(res.cookieDecoder);
@@ -555,6 +558,7 @@ private static void checkRequest(HttpClientRequest request, HttpClientResponse r
else {
assertThat(req.redirecting).isSameAs(res.redirecting);
}
+ assertThat(req.authenticating).isSameAs(res.authenticating);
assertThat(req.responseState).isNotSameAs(res.responseState);
assertThat(req.version).isNotSameAs(res.version);
}
@@ -563,11 +567,79 @@ private static void checkRequest(HttpClientRequest request, HttpClientResponse r
assertThat(req.asShortText()).isSameAs(res.asShortText());
assertThat(req.started).isSameAs(res.started);
assertThat(req.redirecting).isSameAs(res.redirecting);
+ assertThat(req.authenticating).isSameAs(res.authenticating);
assertThat(req.responseState).isSameAs(res.responseState);
assertThat(req.version).isSameAs(res.version);
}
}
+ @ParameterizedTest
+ @MethodSource("httpCompatibleProtocols")
+ void testConstructorWithProvidedAuthentication(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols,
+ SslProvider.@Nullable ProtocolSslContextSpec serverCtx, SslProvider.@Nullable ProtocolSslContextSpec clientCtx) {
+ ConnectionProvider provider = ConnectionProvider.create("testConstructorWithProvidedAuthentication", 1);
+ try {
+ HttpServer server = serverCtx == null ?
+ createServer().protocol(serverProtocols) :
+ createServer().protocol(serverProtocols).secure(spec -> spec.sslContext((SslProvider.GenericSslContextSpec>) serverCtx));
+
+ disposableServer =
+ server.route(r -> r.get("/protected", (req, res) -> {
+ String authHeader = req.requestHeaders().get(HttpHeaderNames.AUTHORIZATION);
+ if (authHeader == null || !authHeader.equals("Bearer test-token")) {
+ return res.status(HttpResponseStatus.UNAUTHORIZED).send();
+ }
+ return res.sendString(Mono.just("testConstructorWithProvidedAuthentication"));
+ }))
+ .bindNow();
+
+ HttpClient client = clientCtx == null ?
+ createClient(disposableServer.port()).protocol(clientProtocols) :
+ createClient(disposableServer.port()).protocol(clientProtocols).secure(spec -> spec.sslContext((SslProvider.GenericSslContextSpec>) clientCtx));
+
+ AtomicReference<@Nullable HttpClientRequest> request = new AtomicReference<>();
+ AtomicReference<@Nullable HttpClientResponse> response = new AtomicReference<>();
+ AtomicReference<@Nullable Channel> requestChannel = new AtomicReference<>();
+ AtomicReference<@Nullable Channel> responseChannel = new AtomicReference<>();
+ AtomicReference<@Nullable ConnectionObserver> requestListener = new AtomicReference<>();
+ AtomicReference<@Nullable ConnectionObserver> responseListener = new AtomicReference<>();
+ String result = httpAuthentication(client, request, response, requestChannel, responseChannel,
+ requestListener, responseListener);
+ assertThat(result).isNotNull().isEqualTo("testConstructorWithProvidedAuthentication");
+ assertThat(requestListener.get()).isSameAs(responseListener.get());
+ checkRequest(request.get(), response.get(), requestChannel.get(), responseChannel.get(), false, false);
+ }
+ finally {
+ provider.disposeLater()
+ .block(Duration.ofSeconds(5));
+ }
+ }
+
+ private static @Nullable String httpAuthentication(HttpClient originalClient, AtomicReference<@Nullable HttpClientRequest> request,
+ AtomicReference<@Nullable HttpClientResponse> response, AtomicReference<@Nullable Channel> requestChannel,
+ AtomicReference<@Nullable Channel> responseChannel, AtomicReference<@Nullable ConnectionObserver> requestListener,
+ AtomicReference<@Nullable ConnectionObserver> responseListener) {
+ HttpClient client = originalClient.httpAuthentication(
+ (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED),
+ (req, addr) -> {
+ req.header(HttpHeaderNames.AUTHORIZATION, "Bearer test-token");
+ });
+ return client.doAfterRequest((req, conn) -> {
+ requestChannel.set(conn.channel());
+ requestListener.set(((HttpClientOperations) req).listener());
+ request.set(req);
+ })
+ .doOnResponse((res, conn) -> {
+ responseChannel.set(conn.channel());
+ responseListener.set(((HttpClientOperations) res).listener());
+ response.set(res);
+ })
+ .get()
+ .uri("/protected")
+ .responseSingle((res, bytes) -> bytes.asString())
+ .block(Duration.ofSeconds(5));
+ }
+
static Stream httpCompatibleProtocols() {
return Stream.of(
Arguments.of(new HttpProtocol[]{HttpProtocol.HTTP11}, new HttpProtocol[]{HttpProtocol.HTTP11}, null, null),
diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java
index 44db3958a..de0d14bf6 100644
--- a/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java
+++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java
@@ -3809,6 +3809,467 @@ void testSelectedIpsDelayedAddressResolution() {
.verify(Duration.ofSeconds(5));
}
+ @Test
+ void testHttpAuthentication() {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ AtomicBoolean authHeaderAdded = new AtomicBoolean(false);
+
+ disposableServer =
+ HttpServer.create()
+ .port(0)
+ .handle((req, res) -> {
+ int count = requestCount.incrementAndGet();
+ String authHeader = req.requestHeaders().get(HttpHeaderNames.AUTHORIZATION);
+
+ if (count == 1) {
+ // First request should not have auth header
+ assertThat(authHeader).isNull();
+ return res.status(HttpResponseStatus.UNAUTHORIZED).send();
+ }
+ else {
+ // Second request should have auth header
+ assertThat(authHeader).isEqualTo("Bearer test-token");
+ return res.status(HttpResponseStatus.OK)
+ .sendString(Mono.just("Authenticated!"));
+ }
+ })
+ .bindNow();
+
+ HttpClient client =
+ HttpClient.create()
+ .port(disposableServer.port())
+ .httpAuthentication(
+ (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED),
+ (req, addr) -> {
+ authHeaderAdded.set(true);
+ req.header(HttpHeaderNames.AUTHORIZATION, "Bearer test-token");
+ });
+
+ String response = client.get()
+ .uri("/protected")
+ .responseContent()
+ .aggregate()
+ .asString()
+ .block(Duration.ofSeconds(5));
+
+ assertThat(response).isEqualTo("Authenticated!");
+ assertThat(requestCount.get()).isEqualTo(2);
+ assertThat(authHeaderAdded.get()).isTrue();
+ }
+
+ @Test
+ void testHttpAuthenticationNoRetryWhenPredicateDoesNotMatch() {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ AtomicBoolean authenticatorCalled = new AtomicBoolean(false);
+
+ disposableServer =
+ HttpServer.create()
+ .port(0)
+ .handle((req, res) -> {
+ requestCount.incrementAndGet();
+ // Always return 403 Forbidden
+ return res.status(HttpResponseStatus.FORBIDDEN).send();
+ })
+ .bindNow();
+
+ HttpClient client =
+ HttpClient.create()
+ .port(disposableServer.port())
+ .httpAuthenticationWhen(
+ // Only retry on 401, not 403
+ (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED),
+ (req, addr) -> {
+ authenticatorCalled.set(true);
+ req.header(HttpHeaderNames.AUTHORIZATION, "Bearer test-token");
+ return Mono.empty();
+ }
+ );
+
+ client.get()
+ .uri("/protected")
+ .responseSingle((res, content) -> Mono.just(res.status()))
+ .as(StepVerifier::create)
+ .expectNext(HttpResponseStatus.FORBIDDEN)
+ .expectComplete()
+ .verify(Duration.ofSeconds(5));
+
+ // Should only make one request since predicate doesn't match
+ assertThat(requestCount.get()).isEqualTo(1);
+ assertThat(authenticatorCalled.get()).isFalse();
+ }
+
+ @Test
+ void testHttpAuthenticationWithMonoAuthenticator() {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ AtomicInteger authCallCount = new AtomicInteger(0);
+
+ disposableServer =
+ HttpServer.create()
+ .port(0)
+ .handle((req, res) -> {
+ int count = requestCount.incrementAndGet();
+ String authHeader = req.requestHeaders().get(HttpHeaderNames.AUTHORIZATION);
+
+ if (count == 1) {
+ assertThat(authHeader).isNull();
+ return res.status(HttpResponseStatus.UNAUTHORIZED).send();
+ }
+ else {
+ assertThat(authHeader).startsWith("Bearer async-token-");
+ return res.status(HttpResponseStatus.OK)
+ .sendString(Mono.just("Success"));
+ }
+ })
+ .bindNow();
+
+ HttpClient client =
+ HttpClient.create()
+ .port(disposableServer.port())
+ .httpAuthenticationWhen(
+ (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED),
+ (req, addr) -> {
+ int callNum = authCallCount.incrementAndGet();
+ // Simulate async token generation
+ return Mono.delay(Duration.ofMillis(100))
+ .then(Mono.fromRunnable(() ->
+ req.header(HttpHeaderNames.AUTHORIZATION,
+ "Bearer async-token-" + callNum)));
+ }
+ );
+
+ String response = client.get()
+ .uri("/api/resource")
+ .responseContent()
+ .aggregate()
+ .asString()
+ .block(Duration.ofSeconds(5));
+
+ assertThat(response).isEqualTo("Success");
+ assertThat(requestCount.get()).isEqualTo(2);
+ assertThat(authCallCount.get()).isEqualTo(1);
+ }
+
+ @Test
+ void testHttpAuthenticationMultipleRequests() {
+ AtomicInteger requestCount = new AtomicInteger(0);
+
+ disposableServer =
+ HttpServer.create()
+ .port(0)
+ .handle((req, res) -> {
+ requestCount.incrementAndGet();
+ String authHeader = req.requestHeaders().get(HttpHeaderNames.AUTHORIZATION);
+
+ if (authHeader == null || !authHeader.equals("Bearer valid-token")) {
+ return res.status(HttpResponseStatus.UNAUTHORIZED).send();
+ }
+ else {
+ return res.status(HttpResponseStatus.OK)
+ .sendString(Mono.just("OK"));
+ }
+ })
+ .bindNow();
+
+ HttpClient client =
+ HttpClient.create()
+ .port(disposableServer.port())
+ .httpAuthenticationWhen(
+ (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED),
+ (req, addr) -> {
+ req.header(HttpHeaderNames.AUTHORIZATION, "Bearer valid-token");
+ return Mono.empty();
+ }
+ );
+
+ // Make multiple requests
+ for (int i = 0; i < 3; i++) {
+ String response = client.get()
+ .uri("/resource")
+ .responseContent()
+ .aggregate()
+ .asString()
+ .block(Duration.ofSeconds(5));
+ assertThat(response).isEqualTo("OK");
+ }
+
+ // Each request should trigger auth flow (1 initial + 1 retry) = 6 total
+ assertThat(requestCount.get()).isEqualTo(6);
+ }
+
+ @Test
+ void testHttpAuthenticationWithCustomStatusCode() {
+ AtomicInteger requestCount = new AtomicInteger(0);
+
+ disposableServer =
+ HttpServer.create()
+ .port(0)
+ .handle((req, res) -> {
+ int count = requestCount.incrementAndGet();
+ String authHeader = req.requestHeaders().get("WWW-Authenticate");
+
+ if (count == 1) {
+ assertThat(authHeader).isNull();
+ // Return custom status code 407 (Proxy Authentication Required)
+ return res.status(HttpResponseStatus.PROXY_AUTHENTICATION_REQUIRED).send();
+ }
+ else {
+ assertThat(authHeader).isEqualTo("Negotiate custom-token");
+ return res.status(HttpResponseStatus.OK)
+ .sendString(Mono.just("Authorized"));
+ }
+ })
+ .bindNow();
+
+
+ HttpClient client =
+ HttpClient.create()
+ .port(disposableServer.port())
+ .httpAuthenticationWhen(
+ // Retry on 407 instead of 401
+ (req, res) -> res.status().equals(HttpResponseStatus.PROXY_AUTHENTICATION_REQUIRED),
+ (req, addr) -> {
+ req.header("WWW-Authenticate", "Negotiate custom-token");
+ return Mono.empty();
+ }
+ );
+
+ String response = client.get()
+ .uri("/proxy-protected")
+ .responseContent()
+ .aggregate()
+ .asString()
+ .block(Duration.ofSeconds(5));
+
+ assertThat(response).isEqualTo("Authorized");
+ assertThat(requestCount.get()).isEqualTo(2);
+ }
+
+ @Test
+ void testHttpAuthenticationMaxRetries() {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ AtomicInteger authenticatorCallCount = new AtomicInteger(0);
+
+ disposableServer =
+ HttpServer.create()
+ .port(0)
+ .handle((req, res) -> {
+ requestCount.incrementAndGet();
+ // Server always returns 401, forcing client to hit retry limit
+ return res.status(HttpResponseStatus.UNAUTHORIZED).send();
+ })
+ .bindNow();
+
+ HttpClient client =
+ HttpClient.create()
+ .port(disposableServer.port())
+ .httpAuthenticationWhen(
+ (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED),
+ (req, addr) -> {
+ authenticatorCallCount.incrementAndGet();
+ req.header(HttpHeaderNames.AUTHORIZATION, "Bearer retry-token");
+ return Mono.empty();
+ },
+ 3 // Set maxRetries to 3
+ );
+
+ // After 3 retry attempts, request should complete with 401 status
+ client.get()
+ .uri("/protected")
+ .responseSingle((res, bytes) -> bytes.then(Mono.just(res.status())))
+ .as(StepVerifier::create)
+ .expectNext(HttpResponseStatus.UNAUTHORIZED)
+ .expectComplete()
+ .verify(Duration.ofSeconds(5));
+
+ // Total requests = 1 initial + 3 retries = 4
+ assertThat(requestCount.get()).isEqualTo(4);
+ // Authenticator should be called 3 times (once per retry)
+ assertThat(authenticatorCallCount.get()).isEqualTo(3);
+ }
+
+ @Test
+ void testHttpAuthenticationRetriesResetPerRequestHttp1() {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ AtomicInteger authenticatorCallCount = new AtomicInteger(0);
+ Set channelIds = ConcurrentHashMap.newKeySet();
+
+ disposableServer =
+ HttpServer.create()
+ .port(0)
+ .wiretap(true)
+ .handle((req, res) -> {
+ int count = requestCount.incrementAndGet();
+ String authHeader = req.requestHeaders().get(HttpHeaderNames.AUTHORIZATION);
+
+ // Track channel ID to verify connection reuse
+ req.withConnection(conn -> channelIds.add(conn.channel().id()));
+
+ // Always return 401 on first attempt (no auth header)
+ if (authHeader == null || !authHeader.equals("Bearer token")) {
+ return res.status(HttpResponseStatus.UNAUTHORIZED).send();
+ }
+ else {
+ return res.status(HttpResponseStatus.OK)
+ .sendString(Mono.just("OK-" + count));
+ }
+ })
+ .bindNow();
+
+ ConnectionProvider provider = ConnectionProvider.create("test", 1);
+
+ try {
+ HttpClient client =
+ HttpClient.create(provider)
+ .port(disposableServer.port())
+ .protocol(HttpProtocol.HTTP11)
+ .httpAuthenticationWhen(
+ (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED),
+ (req, addr) -> {
+ authenticatorCallCount.incrementAndGet();
+ req.header(HttpHeaderNames.AUTHORIZATION, "Bearer token");
+ return Mono.empty();
+ }
+ );
+
+ // First request: 401 -> retry with auth -> 200
+ String response1 = client.get()
+ .uri("/api/1")
+ .responseContent()
+ .aggregate()
+ .asString()
+ .block(Duration.ofSeconds(5));
+ assertThat(response1).contains("OK");
+
+ // Second request using same connection from pool: should reset authenticationRetries
+ // Should also trigger: 401 -> retry with auth -> 200
+ String response2 = client.get()
+ .uri("/api/2")
+ .responseContent()
+ .aggregate()
+ .asString()
+ .block(Duration.ofSeconds(5));
+ assertThat(response2).contains("OK");
+
+ // Third request: same pattern
+ String response3 = client.get()
+ .uri("/api/3")
+ .responseContent()
+ .aggregate()
+ .asString()
+ .block(Duration.ofSeconds(5));
+ assertThat(response3).contains("OK");
+
+ // Verify:
+ // - Each request triggered auth retry (3 requests × 2 attempts = 6 total server requests)
+ assertThat(requestCount.get()).isEqualTo(6);
+ // - Authenticator was called 3 times (once per request)
+ assertThat(authenticatorCallCount.get()).isEqualTo(3);
+ // - Connection was reused (HTTP/1.1 keep-alive with pool size 1)
+ // All requests should use the same channel
+ assertThat(channelIds).hasSize(1);
+ }
+ finally {
+ provider.disposeLater().block(Duration.ofSeconds(5));
+ }
+ }
+
+ @Test
+ void testHttpAuthenticationRetriesResetPerRequestHttp2() throws Exception {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ AtomicInteger authenticatorCallCount = new AtomicInteger(0);
+ Set parentChannelIds = ConcurrentHashMap.newKeySet();
+
+ SslContext sslServer = SslContextBuilder.forServer(ssc.toTempCertChainPem(), ssc.toTempPrivateKeyPem())
+ .build();
+ SslContext sslClient = SslContextBuilder.forClient()
+ .trustManager(InsecureTrustManagerFactory.INSTANCE)
+ .build();
+
+ disposableServer =
+ HttpServer.create()
+ .port(0)
+ .protocol(HttpProtocol.H2)
+ .secure(spec -> spec.sslContext(sslServer))
+ .wiretap(true)
+ .handle((req, res) -> {
+ int count = requestCount.incrementAndGet();
+ String authHeader = req.requestHeaders().get(HttpHeaderNames.AUTHORIZATION);
+
+ // Track parent channel ID (HTTP/2 connection) to verify reuse
+ req.withConnection(conn -> {
+ if (conn.channel().parent() != null) {
+ parentChannelIds.add(conn.channel().parent().id());
+ }
+ });
+
+ // Always return 401 on first attempt (no auth header)
+ if (authHeader == null || !authHeader.equals("Bearer h2-token")) {
+ return res.status(HttpResponseStatus.UNAUTHORIZED).send();
+ }
+ else {
+ return res.status(HttpResponseStatus.OK)
+ .sendString(Mono.just("OK-H2-" + count));
+ }
+ })
+ .bindNow();
+
+ ConnectionProvider provider = ConnectionProvider.create("test-h2", 1);
+
+ try {
+ HttpClient client =
+ HttpClient.create(provider)
+ .port(disposableServer.port())
+ .protocol(HttpProtocol.H2)
+ .secure(spec -> spec.sslContext(sslClient))
+ .httpAuthenticationWhen(
+ (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED),
+ (req, addr) -> {
+ authenticatorCallCount.incrementAndGet();
+ req.header(HttpHeaderNames.AUTHORIZATION, "Bearer h2-token");
+ return Mono.empty();
+ }
+ );
+
+ // First request: 401 -> retry with auth -> 200
+ String response1 = client.get()
+ .uri("/api/1")
+ .responseContent()
+ .aggregate()
+ .asString()
+ .block(Duration.ofSeconds(5));
+ assertThat(response1).contains("OK-H2");
+
+ // Second request on same HTTP/2 connection (new stream): should reset authenticationRetries
+ String response2 = client.get()
+ .uri("/api/2")
+ .responseContent()
+ .aggregate()
+ .asString()
+ .block(Duration.ofSeconds(5));
+ assertThat(response2).contains("OK-H2");
+
+ // Third request: same pattern
+ String response3 = client.get()
+ .uri("/api/3")
+ .responseContent()
+ .aggregate()
+ .asString()
+ .block(Duration.ofSeconds(5));
+ assertThat(response3).contains("OK-H2");
+
+ // Verify:
+ // - Each request triggered auth retry (3 requests × 2 attempts = 6 total)
+ assertThat(requestCount.get()).isEqualTo(6);
+ // - Authenticator was called 3 times (once per request)
+ assertThat(authenticatorCallCount.get()).isEqualTo(3);
+ // - Same HTTP/2 connection was reused for all streams
+ assertThat(parentChannelIds).hasSize(1);
+ }
+ finally {
+ provider.disposeLater().block(Duration.ofSeconds(5));
+ }
+ }
+
private static final class EchoAction implements Publisher, Consumer {
private final Publisher sender;
private volatile FluxSink emitter;