I can't believe how hard it is to find an answer to this question online. So I'm writing an article about it.

If you're using WebClient to handle downstream requests, you might have run into a problem.

As in this problem: "The underlying HTTP client completed without emitting a response." You'll see that as part of an IllegalStateException.

You'll get that if the downstream service doesn't emit a response but you're trying to log the response.

That happens a lot. Sometimes, for example, if you aren't authenticated, the service will just send back an empty response with a 401 (Not Authorized) status.

That kind of makes sense. After all, 401 means "not authorized" so any self-respecting client should be able to parse that status code and respond accordingly.

And yes, WebClient can handle that situation. It's just that you might not know how to handle it if you're new here.

Refactoring & Handling Errors

In this guide, I'll basically refactor my strategy for handling errors with Spring WebFlux.

First thing's first. Take the error handling out of logResponse() in WebClientFilter.

	public static ExchangeFilterFunction logResponse() {
		return ExchangeFilterFunction.ofResponseProcessor(response -> {
			logStatus(response);
			logHeaders(response);
			
			return Mono.just(response);
		});
	}

Now all it does is log the status and the headers. Good enough for that method.

I didn't like including the part that handles errors because that method isn't meant to handle errors. It's just meant for logging.

Speaking of handling errors, by the way, it's time to create a method that does just that.

	public static ExchangeFilterFunction handleError() {
	    return ExchangeFilterFunction.ofResponseProcessor(response -> {
    		if (response.statusCode() != null && (response.statusCode().is4xxClientError() || response.statusCode().is5xxServerError())) {
    			return response.bodyToMono(String.class)
    			        .defaultIfEmpty(response.statusCode().getReasonPhrase())
    					.flatMap(body -> {
    						LOG.debug("Body is {}", body);						
    						return Mono.error(new ServiceException(body, response.rawStatusCode()));
    					});
    		} else {
    			return Mono.just(response);
    		}
	    });
	}

Most of that you've seen before. It was in the logResponse() body in the old guide.

Now I've moved it to a method that just handles errors. Makes life easier.

And then I did something else.

Pay very close attention to that defaultIfEmpty() method above. That's where it handles empty responses.

In this case, if the downstream service returns an empty response with just a status code, the application will construct a response string on the fly using the message associated with that status code. That's why it uses the getReasonPhrase() method.

Now you just need to update the client construction in UserService to make use of the new filter:

    public UserService(@Value("${ecosystem.properties.file.location}") String ecosystemFile) {
        PropertiesUtil propertiesUtil = new PropertiesUtil(ecosystemFile);
        String endpoint = propertiesUtil.getProperty("ecosystem-user-service.endpoint");
        
        userClient = WebClient
	        		.builder()
	        		.baseUrl(endpoint)
	        		.filter(WebClientFilter.logRequest())
	        		.filter(WebClientFilter.logResponse())
	        		.filter(WebClientFilter.handleError())
	        		.build();
    }

Boom. You're done.

Testing Time

Now let's test this thing out.

I've updated the downstream service (in this case, it's the user service associated with the CRM app) to always return a status code of 400 (Bad Request) when anyone hits the endpoint /user/me.

The response itself will be empty.

The question is: can our code above handle that without throwing that nasty IllegalStateException

Let's test it out with some initialization code that looks like this:

@Component
public class ApplicationListenerInitialize implements ApplicationListener<ApplicationReadyEvent>  {
    
    @Autowired
    private UserService userService;
    
    public void onApplicationEvent(ApplicationReadyEvent event) {
        try {
            SalesOwner owner = userService.fetchUser("Bearer eyJhbGciO...");
            ObjectMapper objectMapper = new ObjectMapper();
            System.err.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(owner));
        } catch (ServiceException e) {
            System.err.println(e.getMessage() + " " + e.getStatusCode());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

I've abbreviated the bearer token above. But you'll need to put a legit one in there if you're planning to reproduce this test.

Now save that file and restart Spring Boot. After it's finished loading, watch the log.

This is what you should see in beautiful red coloring:

Bad Request 400

Aha! Exactly what you're looking for. The status message ("Bad Request") followed by the status code.

As a rule of thumb it's ideal for downstream applications to provide a message (preferably in JSON format) in that situation. But we're just doing some testing here.

Okay, now let's make sure that the application still logs and captures the response if, in fact, the downstream service returns something along with that status code.

I've updated the user service so that it now returns a status of 400 with a response body that reads: "You did something wrong" when anyone hits /user/me.

Once again, restart the application and watch the log. You should see this:

You did something wrong 400

Perfect. That's exactly what you're looking for.

In other words: everything works.

Wrapping It Up

There you have it. That's how to handle empty responses with WebClient.

Feel free to adjust the code above to suit your own purposes.

You can also grab the source on GitHub.

Have fun!

Photo by Debby Hudson on Unsplash