Spring WebClient.Builder timeout defaults and overrides for runtimes
Asked Answered
L

2

2

I'm using Spring Boot 3.0.4 with Java 17. The Spring WebClient documentation says to use the injected WebClient.Builder:

Spring Boot creates and pre-configures a WebClient.Builder for you. It is strongly advised to inject it in your components and use it to create WebClient instances. Spring Boot is configuring that builder to share HTTP resources, reflect codecs setup in the same fashion as the server ones …, and more.

The documentation also says:

Spring Boot will auto-detect which ClientHttpConnector to use to drive WebClient, depending on the libraries available on the application classpath. For now, Reactor Netty, Jetty ReactiveStream client, Apache HttpClient, and the JDK’s HttpClient are supported.

This is a bit unclear to me. I had read in books and articles that Spring Boot will use Netty automatically for WebClient. But does this mean that without further configuration, the latest Spring Boot will use the JDK HttpClient? Note that I have included spring-boot-starter-web and spring-boot-starter-webflux in my project, but nothing specifically relating to Netty.

Furthermore the Spring Reactor documentation tells me that I can configure a connection timeout like this if I am using the Netty runtime:

import io.netty.channel.ChannelOption;

HttpClient httpClient = HttpClient.create()
        .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);

WebClient webClient = WebClient.builder()
        .clientConnector(new ReactorClientHttpConnector(httpClient))
        .build();

But what is the timeout default already, if I don't add this code? And if I don't like the default and want to use this code, how do I override the default WebClient.Builder (mentioned above) without building one from scratch (and possibly negating all the other benefits)?

So let me summarize my doubts, based upon all this slightly ambiguous documentation:

  1. If I only specify spring-boot-starter-web and spring-boot-starter-webflux, is Spring WebClient using Netty or JDK HttpClient. (If it's using Netty by default, why does the documentation even mention JDK HttpClient? How would I force JDK HttpClient?)
  2. What are the default HTTP connection timeouts with the preconfigured WebClient.Builder, and where is this documented (or how can I find this out in the source code)?
  3. How can I override just the connection timeout for the preconfigured WebClient.Builder which is injected into the Spring context automatically?
Lewallen answered 22/4, 2023 at 15:6 Comment(0)
K
3
  1. HoaPhan has already pointed out the code that checks for the presence of different HttpClient classes in the classpath. Based on those checks, the initialization of a ClientHttpConnector object happens in this method. As we can see in the linked code(included below), a JdkClientHttpConnector gets initialized if none of the other libraries are present in the classpath

    private ClientHttpConnector initConnector() {
        if (reactorNettyClientPresent) {
            return new ReactorClientHttpConnector();
        }
        else if (reactorNetty2ClientPresent) {
            return new ReactorNetty2ClientHttpConnector();
        }
        else if (jettyClientPresent) {
            return new JettyClientHttpConnector();
        }
        else if (httpComponentsClientPresent) {
            return new HttpComponentsClientHttpConnector();
        }
        else {
            return new JdkClientHttpConnector();
        }
    }

Looks like netty gets added as a transient dependency when using spring-boot-starter-webflux. So to force the code to use the JDK HttpClient, you can do either what HoaPhan suggested or exclude netty dependencies from the classpath, which on gradle would look something like below:

    implementation(group: 'org.springframework.boot', name: 'spring-boot-starter-webflux') {
        exclude group: 'io.projectreactor.netty'
    }

With the above exclusion and with none of the other HttpClient libraries present in the classpath, a WebClient bean like below should use the JDK HttpClient:

    @Bean
    public WebClient webClient(WebClient.Builder builder) {
        return builder.build();
    }
  1. The default connect timeout, if using the netty client, is 30 seconds. The timeouts are documented here

  2. Overriding the timeout in the preconfigured WebClient.Builder bean can be done using the same code you have included in the question, substituting WebClient.builder() with the injected WebClient.Builder bean. Something like below:

package io.github.devatherock.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;

import io.netty.channel.ChannelOption;
import reactor.netty.http.client.HttpClient;

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient(WebClient.Builder builder) {
        HttpClient httpClient = HttpClient.create()
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);

        return builder
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .build();
    }
}

UPDATE:

To create WebClient objects with different configurations from the single pre-configured WebClient.Builder bean, we'll need to clone the builder bean first

    @Bean
    public WebClient accountsClient(WebClient.Builder builder) {
        HttpClient httpClient = HttpClient.create()
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);

        return builder.clone()
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .build();
    }
    
    @Bean
    public WebClient payrollClient(WebClient.Builder builder) {
        HttpClient httpClient = HttpClient.create()
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000);

        return builder.clone()
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .build();
    }

UPDATE 2:

To set timeouts and other customizations to the pre-configured WebClient.Builder bean, the simplest way would be to provide a custom WebClientCustomizer bean, like HoaPhan pointed out, as spring-boot applies all customizers to the WebClient.Builder bean when it is created. Then the customized WebClient.Builder bean can used to create as many WebClient objects as required. Sample below:

package io.github.devatherock.config;

import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;

import io.netty.channel.ChannelOption;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import reactor.netty.http.client.HttpClient;

@Configuration
public class WebClientConfig {
    
    @Bean
    public WebClientCustomizer timeoutCustomizer() {
        return (builder) -> {
            HttpClient httpClient = HttpClient.create().option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);
            builder.clientConnector(new ReactorClientHttpConnector(httpClient));
        };
    }

    @Bean
    public GoogleService googleService(WebClient.Builder builder) {
        WebClient googleClient = builder
                .baseUrl("https://www.google.com")
                .build();

        return new GoogleService(googleClient);
    }

    @Bean
    public BingService bingService(WebClient.Builder builder) {
        WebClient bingClient = builder
                .baseUrl("https://www.bing.com")
                .build();

        return new BingService(bingClient);
    }
}
Kadner answered 28/4, 2023 at 14:46 Comment(8)
"Overriding the timeout in the preconfigured WebClient.Builder bean can be done … substituting WebClient.builder() with the injected WebClient.Builder bean." Right, that now gives me a WebClient that I can inject everywhere. But what if I want to keep injecting a WebClient.Builder everywhere, using all the defaults Spring Boot already configures for WebClient.Builder, except now the injected WebClient.Builder has a timeout configured? For example, individual beans may want to generate their own WebClient instances with a default API URI, using the injected WebClient.Builder.Lewallen
The WebClient.Builder interface exposes a clone() method that can be used to create multiple WebClient beans with different timeouts, from the single builder bean injected by spring boot. Will add an exampleKadner
It's good to know I can clone the builder, but that still doesn't address what I want. Your example still shows making a WebClient available for injection. I want to do the opposite of clone. I want to expose a single WebClient.Builder (like Spring does already) that I can inject anywhere, but I just want it to have a timeout specified (in addition to the other default settings). Maybe you're saying that I can't do that, and I have to live with injecting a WebClient and cloning it each time.Lewallen
Sorry, my bad - I misunderstood your 3rd question twice! A WebClientCustomizer should give you what you are expecting. I have updated the answer again, hopefully correctly this timeKadner
In my experience, it actually makes more sense to have @Config prepare different WebClient, eg: AuthClient, AuthzClient, AttributeClient, OrderClient, FeatureFlagClient... because each of them has a very different nature of connection/cache/security/retryStrategy/rate-limit... Should you feel it'd be practical to have 1 client to rule them all, maybe raise an issue/pr on the spring repo or reactor-netty repo(you can introduce sys env change default behavior of netty construct, bypassing spring). Maybe there are others who have the same need.Hurtless
I'm only now getting a chance to try this. It looks like you are creating a whole new HttpClient. But doesn't default WebClient.Builder create an HttpClient already? Does it have any special settings it uses? If I create an HttpClient manually, won't that discard any special setup that Spring is doing with the default HttpClient used for the default WebClient.Builder? The point here is to use all the default settings that Spring uses, and only modify the connection timeout. Does Spring perform any setup on its HttpClient that I would be discarding with this approach?Lewallen
No, the WebClient.Builder bean initialized by spring-boot just calls WebClient.builder() which returns a DefaultWebClientBuilder object without any customization. The only other thing spring-boot does is apply all the available WebClientCustomizer beans to the created WebClient.Builder object, one of which is what we are using to set the timeout. The clientConnector, which wraps the HttpClient, gets initialized only when creating the WebClient object, by calling build() method on the WebClient.Builder beanKadner
I wanted to stop by and say thanks. The WebClientCustomizer works well with other customizers such as Jackson2ObjectMapperBuilderCustomizer, as I explain in my answer to another question.Lewallen
H
4
  1. If you check https://github.com/spring-projects/spring-framework/blob/main/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java#L56 you'll see its selection of Webclient impl base on whether they are in the classpath. Notice at the top it does

    static {
    ClassLoader loader = DefaultWebClientBuilder.class.getClassLoader();
    reactorNettyClientPresent = ClassUtils.isPresent("reactor.netty.http.client.HttpClient", loader);
    reactorNetty2ClientPresent = ClassUtils.isPresent("reactor.netty5.http.client.HttpClient", loader);
    jettyClientPresent = ClassUtils.isPresent("org.eclipse.jetty.client.HttpClient", loader);
    httpComponentsClientPresent =
            ClassUtils.isPresent("org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient", loader) &&
                    ClassUtils.isPresent("org.apache.hc.core5.reactive.ReactiveDataConsumer", loader);}
    

Technically you could force it to use JDK http client (since 6.0 https://docs.spring.io/spring-framework/docs/6.0.0-M3/javadoc-api/org/springframework/http/client/reactive/JdkClientHttpConnector.html)

HttpClient httpClient = HttpClient.newHttpClient();
ClientHttpConnector clientConnector = new JdkClientHttpConnector(httpClient);
WebClient client = WebClient.builder().clientConnector(clientConnector).baseUrl("http://google.com").build();
  1. The default timeout if not specified is up to the implementation of a particular HTTP client, for example for Netty, it would be indefinitely (it would wait indefinitely for a response). enter image description here Though the best practice is to configure timeout base on your knowledge of the particular service. See note in next the point.
  2. You could use https://docs.spring.io/spring-boot/docs/current/api/org/springframework/boot/web/reactive/function/client/WebClientCustomizer.html. Though in my experience it's better to construct webclient toward each remote service with timeout and other option(proxy, TLS, cache ...) base on the SLA of that particular target(some service have longer response time, some requires proxy, some required private certificate, some use MTLS ...). Webclient like Netty have full fledge built-in metric that can be enabled so that you can start with best guess and by time, observe and adjust the config of webclient to better suite the particular client-server dynamic.
Hurtless answered 25/4, 2023 at 19:0 Comment(1)
Thank you for taking the time to provide this information. @Kadner gave me a cleaner way to switch to the JDK client, and provided examples for the web client customizer, so they get the bounty. I appreciate your information, too.Lewallen
K
3
  1. HoaPhan has already pointed out the code that checks for the presence of different HttpClient classes in the classpath. Based on those checks, the initialization of a ClientHttpConnector object happens in this method. As we can see in the linked code(included below), a JdkClientHttpConnector gets initialized if none of the other libraries are present in the classpath

    private ClientHttpConnector initConnector() {
        if (reactorNettyClientPresent) {
            return new ReactorClientHttpConnector();
        }
        else if (reactorNetty2ClientPresent) {
            return new ReactorNetty2ClientHttpConnector();
        }
        else if (jettyClientPresent) {
            return new JettyClientHttpConnector();
        }
        else if (httpComponentsClientPresent) {
            return new HttpComponentsClientHttpConnector();
        }
        else {
            return new JdkClientHttpConnector();
        }
    }

Looks like netty gets added as a transient dependency when using spring-boot-starter-webflux. So to force the code to use the JDK HttpClient, you can do either what HoaPhan suggested or exclude netty dependencies from the classpath, which on gradle would look something like below:

    implementation(group: 'org.springframework.boot', name: 'spring-boot-starter-webflux') {
        exclude group: 'io.projectreactor.netty'
    }

With the above exclusion and with none of the other HttpClient libraries present in the classpath, a WebClient bean like below should use the JDK HttpClient:

    @Bean
    public WebClient webClient(WebClient.Builder builder) {
        return builder.build();
    }
  1. The default connect timeout, if using the netty client, is 30 seconds. The timeouts are documented here

  2. Overriding the timeout in the preconfigured WebClient.Builder bean can be done using the same code you have included in the question, substituting WebClient.builder() with the injected WebClient.Builder bean. Something like below:

package io.github.devatherock.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;

import io.netty.channel.ChannelOption;
import reactor.netty.http.client.HttpClient;

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient(WebClient.Builder builder) {
        HttpClient httpClient = HttpClient.create()
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);

        return builder
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .build();
    }
}

UPDATE:

To create WebClient objects with different configurations from the single pre-configured WebClient.Builder bean, we'll need to clone the builder bean first

    @Bean
    public WebClient accountsClient(WebClient.Builder builder) {
        HttpClient httpClient = HttpClient.create()
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);

        return builder.clone()
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .build();
    }
    
    @Bean
    public WebClient payrollClient(WebClient.Builder builder) {
        HttpClient httpClient = HttpClient.create()
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000);

        return builder.clone()
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .build();
    }

UPDATE 2:

To set timeouts and other customizations to the pre-configured WebClient.Builder bean, the simplest way would be to provide a custom WebClientCustomizer bean, like HoaPhan pointed out, as spring-boot applies all customizers to the WebClient.Builder bean when it is created. Then the customized WebClient.Builder bean can used to create as many WebClient objects as required. Sample below:

package io.github.devatherock.config;

import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;

import io.netty.channel.ChannelOption;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import reactor.netty.http.client.HttpClient;

@Configuration
public class WebClientConfig {
    
    @Bean
    public WebClientCustomizer timeoutCustomizer() {
        return (builder) -> {
            HttpClient httpClient = HttpClient.create().option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);
            builder.clientConnector(new ReactorClientHttpConnector(httpClient));
        };
    }

    @Bean
    public GoogleService googleService(WebClient.Builder builder) {
        WebClient googleClient = builder
                .baseUrl("https://www.google.com")
                .build();

        return new GoogleService(googleClient);
    }

    @Bean
    public BingService bingService(WebClient.Builder builder) {
        WebClient bingClient = builder
                .baseUrl("https://www.bing.com")
                .build();

        return new BingService(bingClient);
    }
}
Kadner answered 28/4, 2023 at 14:46 Comment(8)
"Overriding the timeout in the preconfigured WebClient.Builder bean can be done … substituting WebClient.builder() with the injected WebClient.Builder bean." Right, that now gives me a WebClient that I can inject everywhere. But what if I want to keep injecting a WebClient.Builder everywhere, using all the defaults Spring Boot already configures for WebClient.Builder, except now the injected WebClient.Builder has a timeout configured? For example, individual beans may want to generate their own WebClient instances with a default API URI, using the injected WebClient.Builder.Lewallen
The WebClient.Builder interface exposes a clone() method that can be used to create multiple WebClient beans with different timeouts, from the single builder bean injected by spring boot. Will add an exampleKadner
It's good to know I can clone the builder, but that still doesn't address what I want. Your example still shows making a WebClient available for injection. I want to do the opposite of clone. I want to expose a single WebClient.Builder (like Spring does already) that I can inject anywhere, but I just want it to have a timeout specified (in addition to the other default settings). Maybe you're saying that I can't do that, and I have to live with injecting a WebClient and cloning it each time.Lewallen
Sorry, my bad - I misunderstood your 3rd question twice! A WebClientCustomizer should give you what you are expecting. I have updated the answer again, hopefully correctly this timeKadner
In my experience, it actually makes more sense to have @Config prepare different WebClient, eg: AuthClient, AuthzClient, AttributeClient, OrderClient, FeatureFlagClient... because each of them has a very different nature of connection/cache/security/retryStrategy/rate-limit... Should you feel it'd be practical to have 1 client to rule them all, maybe raise an issue/pr on the spring repo or reactor-netty repo(you can introduce sys env change default behavior of netty construct, bypassing spring). Maybe there are others who have the same need.Hurtless
I'm only now getting a chance to try this. It looks like you are creating a whole new HttpClient. But doesn't default WebClient.Builder create an HttpClient already? Does it have any special settings it uses? If I create an HttpClient manually, won't that discard any special setup that Spring is doing with the default HttpClient used for the default WebClient.Builder? The point here is to use all the default settings that Spring uses, and only modify the connection timeout. Does Spring perform any setup on its HttpClient that I would be discarding with this approach?Lewallen
No, the WebClient.Builder bean initialized by spring-boot just calls WebClient.builder() which returns a DefaultWebClientBuilder object without any customization. The only other thing spring-boot does is apply all the available WebClientCustomizer beans to the created WebClient.Builder object, one of which is what we are using to set the timeout. The clientConnector, which wraps the HttpClient, gets initialized only when creating the WebClient object, by calling build() method on the WebClient.Builder beanKadner
I wanted to stop by and say thanks. The WebClientCustomizer works well with other customizers such as Jackson2ObjectMapperBuilderCustomizer, as I explain in my answer to another question.Lewallen

© 2022 - 2024 — McMap. All rights reserved.