Demystifying WebClient in Spring WebFlux: Handling Headers, Logging, and Attributes

Demystifying WebClient in Spring WebFlux: Handling Headers, Logging, and Attributes
Photo by Hasmik Ghazaryan Olson / Unsplash

In the world of reactive programming, Spring WebFlux's WebClient has become a popular choice for making HTTP requests. It offers a powerful and flexible way to interact with remote services. However, it's essential to understand that WebClient behaves differently from the traditional RestTemplate, particularly when it comes to handling headers, logging requests and responses, and managing attributes.

In this article, we'll delve into some key differences and best practices for using WebClient effectively in these scenarios.

Content-Length and Host Headers: A Closer Look

Content-Length Header

The Content-Length header specifies the length of the request or response body in bytes. When using RestTemplate, this header is automatically recalculated based on the actual payload size. However, in the world of WebClient, it's important to note that the framework doesn't automatically overwrite an explicitly set Content-Length header.

This can have consequences. If you set the Content-Length header incorrectly, mismatching the actual payload size, the server might interpret the request incorrectly, leading to a BadRequest (400) response. To avoid this, the best practice is to omit setting the Content-Length header altogether. WebClient is designed to calculate the Content-Length automatically based on the payload.

Host Header

The Host header specifies the target host of the request. RestTemplate automatically derives this header from the provided URI, ensuring that it matches the destination. However, WebClient does not override a manually set Host header.

Setting an incorrect Host header could lead to unintended consequences, such as Service Unavailable (503) or even making requests to the wrong destination. To mitigate this risk, the recommended approach is to avoid setting the Host header explicitly. Let WebClient determine the appropriate Host header from the base URL.

Logging Requests and Responses: A Debugging Necessity

Effective logging is crucial for debugging and tracing requests and responses. WebClient makes it possible to achieve detailed request-response logging through custom ExchangeFilterFunctions.

Setting Up Logging Filters

To log requests and responses, you can create custom ExchangeFilterFunctions. These functions intercept requests and responses, allowing you to inspect and log the details.

Here's an example of how to set up logging filters for requests and responses:

WebClient webClient = WebClient.builder()
        .filter(logRequest())
        .filter(logResponse())
        // ... other configuration ...
        .build();

Implementing Logging Filters

The custom ExchangeFilterFunction methods logRequest and logResponse handle the actual logging. Let's take a closer look:

private ExchangeFilterFunction logRequest() {
    return ExchangeFilterFunction.ofRequestProcessor(request -> {
        // Log request details
        // ...
        return Mono.just(request);
    });
}

private ExchangeFilterFunction logResponse() {
    return ExchangeFilterFunction.ofResponseProcessor(response -> {
        // Log response details
        // ...
        return Mono.just(response);
    });
}

Customizing logPrefix

You can enhance your logging by customizing the logPrefix, providing more context to your logs. For instance, you might want to include a transaction ID, HTTP method, and URL.

Here's how you can do it:

webClient.method(method)
        .attribute(LOG_ID_ATTRIBUTE, buildLogPrefix(transactionId, method, url))
        // ... other configuration ...
        .exchangeToMono(/* ... */)
        .retryWhen(/* ... */)
        .block();

Putting It All Together: Complete WebClient Example

Now, let's put everything we've discussed into a complete example. We'll set up a WebClient, customize the logPrefix, and see how the logging filters capture the request and response details.

public class WebClientHelper {

    private final ObjectMapper objectMapper;
    private static final int UNLIMITED_WEB_CLIENT_MAX_IN_MEMORY_SIZE = -1;
    private static final String TRANSACTION_ID =  "transaction_id";

    public <T, R> R apiCall(final String url, final HttpMethod method, T payload, MultiValueMap<String, String> requestHeaders,
            Class<R> responseType) {
        return buildDefaultWebClient(url)
                .method(method)
                // set logPrefix to transaction_id + url
                .attribute(LOG_ID_ATTRIBUTE, buildLogPrefix(requestHeaders.getFirst(TRANSACTION_ID), method, url))
                .headers(headers -> headers.addAll(transform(requestHeaders)))
                .body(BodyInserters.fromValue(payload != null ? payload : ""))
                .retrieve()
                .bodyToMono(responseType)
                .retryWhen(retrySpec())
                .block();
    }

    public <T, R> ResponseEntity<R> apiCallWithStatus(final String url, final HttpMethod method, T payload,
            MultiValueMap<String, String> requestHeaders, Class<R> responseType) {
        return buildDefaultWebClient(url)
                .method(method)
                // set logPrefix to transaction_id + url
                .attribute(LOG_ID_ATTRIBUTE, buildLogPrefix(requestHeaders.getFirst(TRANSACTION_ID), method, url))
                .headers(headers -> headers.addAll(transform(requestHeaders)))
                .body(BodyInserters.fromValue(payload != null ? payload : ""))
                .exchangeToMono(clientResponse -> clientResponse.bodyToMono(responseType)
                        .map(body -> new ResponseEntity<>(body, clientResponse.statusCode())))
                .retryWhen(retrySpec())
                .block();
    }

    private String buildLogPrefix(String transactionId, HttpMethod method, String url) {
        return "transactionId="
                + transactionId
                + ", method="
                + method
                + ", url="
                + url;
    }

    private HttpHeaders transform(MultiValueMap<String, String> requestHeaders) {
        HttpHeaders httpHeaders = new HttpHeaders();
        requestHeaders.toSingleValueMap().forEach((key, value) -> {
            // ignore host, RestTemplate will override it if the given value wrong
            // while WebClient will not which might lead to 503 service unavailable
            if (HttpHeaders.HOST.equalsIgnoreCase(key)) {
                return;
            }
            // ignore content length, RestTemplate will override it if the given value wrong
            // while WebClient will not which might lead to BAD_REQUEST from server
            if (HttpHeaders.CONTENT_LENGTH.equalsIgnoreCase(key)) {
                return;
            }
            httpHeaders.add(key, value);
        });
        return httpHeaders;
    }

    private Retry retrySpec() {
        // retry max 3 times in case any 5xx server error
        return Retry.fixedDelay(3, java.time.Duration.ofSeconds(2))
                .filter(throwable -> {
                    if (throwable instanceof WebClientResponseException) {
                        WebClientResponseException ex = (WebClientResponseException) throwable;
                        HttpStatus statusCode = ex.getStatusCode();
                        return statusCode.is5xxServerError();
                    }
                    return false;
                });
    }

    private void logHeaders(HttpHeaders httpHeaders) {
        httpHeaders.forEach((name, values) -> {
            if (!name.equalsIgnoreCase(HttpHeaderNames.AUTHORIZATION.toString())) {
                log.info("{} : {}", name, String.join(" ", values));
            }
        });
    }

    private ExchangeFilterFunction logResponse() {
        return ExchangeFilterFunction.ofResponseProcessor(response -> {
            log.info("Response - {} - {} :", response.logPrefix(), response.statusCode());
            logHeaders(response.headers().asHttpHeaders());
            return Mono.just(response);
        });
    }

    private ExchangeFilterFunction logRequest() {
        return ExchangeFilterFunction.ofRequestProcessor(request -> {
            log.info("Request - {} -:", request.logPrefix());
            logHeaders(request.headers());
            return Mono.just(request);
        });
    }

    private WebClient buildDefaultWebClient(String baseUrl) {
        return WebClient.builder().baseUrl(baseUrl)
                .filter(logRequest())
                .filter(logResponse())
                .exchangeStrategies(ExchangeStrategies.builder()
                        .codecs(configurer -> {
                            configurer.defaultCodecs().maxInMemorySize(UNLIMITED_WEB_CLIENT_MAX_IN_MEMORY_SIZE);
                            configurer.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(objectMapper));
                            configurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(objectMapper));
                        })
                        .build())
                .build();
    }

}

Conclusion

Understanding the nuances of WebClient in Spring WebFlux is essential for building robust and reliable applications. By grasping the differences in handling headers, mastering request-response logging, and harnessing the power of attributes, you can wield WebClient to its full potential.

In this article, we've explored how to handle Content-Length and Host headers appropriately, implement effective request-response logging using custom filters, and customize the logPrefix to enrich your logs. Armed with this knowledge, you're well-equipped to navigate the intricacies of WebClient in your reactive applications.

Remember, WebClient offers more than just making HTTP requests; it's a versatile tool that empowers you to build efficient and reliable communication between microservices and external APIs.