I have 3 micro-service applications. I am trying to do 2 get call async using webclient from reactive package and then combine them whenever I get a response.
Sample code for that:
(referred from - https://docs.spring.io/spring/docs/5.1.9.RELEASE/spring-framework-reference/web-reactive.html#webflux-client-synchronous)
Mono<Person> personMono = client.get().uri("/person/{id}", personId)
.retrieve().bodyToMono(Person.class);
Mono<List<Hobby>> hobbiesMono = client.get().uri("/person/{id}/hobbies", personId)
.retrieve().bodyToFlux(Hobby.class).collectList();
Map<String, Object> data = Mono.zip(personMono, hobbiesMono, (person, hobbies) -> {
Map<String, String> map = new LinkedHashMap<>();
map.put("person", personName);
map.put("hobbies", hobbies);
return map;
})
.block();
My question is how can I add exception handling to the get calls?
How do I check if I got a 404 or 204 or something else?
I have tried:
Adding .onStatus() to the GET calls
.onStatus(HttpStatus::is4xxClientError, clientResponse ->
Mono.error(new Data4xxException(String.format(
"Could not GET data with id: %s from another app, due to error:
%s", key, clientResponse))))
.onStatus(HttpStatus::is5xxServerError, clientResponse ->
Mono.error(new Data5xxException(
String.format("For Data %s, Error Occurred: %s", key, clientResponse))))
Adding exceptionhandlers - but I exactly dont have a controller so this does not seem to be working.
#ExceptionHandler(WebClientException.class)
public Exception handlerWebClientException(WebClientException webClientException) {
return new Data4xxException("Testing", webClientException);
}
Added a class with ControllerAdvice and ExceptionHandler within it
#ControllerAdvice
public class WebFluxExceptionHandler {
#ExceptionHandler(WebClientException.class)
public Exception handlerWebClientException(WebClientException webClientException) {
return new Data4xxException("Testing", webClientException);
}
}
But I don't see them printed in the spring-boot logs.
The Mono.zip.block() method just returns null and does not actually throw any exception.
How do I get the zip method to throw the exception and not return null ?
The way to do it is using onErrorMap in the following way:
Mono<Person> personMono = client.get()
.uri("/person/{id}", personId)
.retrieve()
.bodyToMono(Person.class)
.onErrorMap((Throwable error) -> error);
onErrorMap will make the Mono to actually throw an error when Zip blocks, terminating zip and letting spring or any other class that you want to handle the exception.
You aren't very clear when you ask
"How do I get the zip method to throw the exception and not return null?"
In WebFlux you commonly don't throw exceptions, you propagate exceptions and then handle them. Why? because we are dealing with streams of data, if you throw an exception, the stream ends, client disconnects and the event chain stops.
We still want to maintain the stream of data and handle bad data as it flows through.
You can handle errors using the doOnError method.
.onStatus(HttpStatus::is4xxClientError, clientResponse ->
Mono.error(new Data4xxException(String.format(
"Could not GET data with id: %s from another app, due to error:
%s", key, clientResponse))))
Mono.zip( .. ).doOnError( //Handle your error, log or whatever )
If you want to do something more specific you'll have to update your question with how you want your errors to be handled.
The retrieve() method in WebClient throws a WebClientResponseException
whenever a response with status code 4xx or 5xx is received.
Unlike the retrieve() method, the exchange() method does not throw exceptions in the case of 4xx or 5xx responses. You need to check the status codes yourself and handle them in the way you want to.
Mono<Object> result = webClient.get().uri(URL).exchange().log().flatMap(entity -> {
HttpStatus statusCode = entity.statusCode();
if (statusCode.is4xxClientError() || statusCode.is5xxServerError())
{
return Mono.error(new Exception(statusCode.toString()));
}
return Mono.just(entity);
}).flatMap(clientResponse -> clientResponse.bodyToMono(JSONObject.class))
Reference: https://www.callicoder.com/spring-5-reactive-webclient-webtestclient-examples/
Related
I have a Spring Boot 2.3.1 project, in which I use WebClient to call a remote service.
The remote service is not very reliable and tends to return 500 errors, with and without response bodies. My goal is throw a custom exception that contains the response body (or a default message) so that I can log it, and here's my code :
webClient.get()
.uri(targetServiceUri)
.retrieve()
.onStatus(HttpStatus::is5xxServerError, clientResponse ->
clientResponse.bodyToMono(String.class)
.flatMap(error ->
Mono.error(new MyCustomServiceException(error))
)
)
.toEntity(String.class)
.block();
I have 2 tests using wiremock, the first one works :
#Test
void shouldThrowCustomExceptionWhenServiceReturns500ServerErrorWithNoBody() {
setStubForInValidCheckCall(HttpStatus.INTERNAL_SERVER_ERROR,"{'Error':'invalid request'}");
Throwable thrown =
catchThrowable(() -> myClient.performComplianceCheck(getCompany()));
assertThat(thrown)
.isInstanceOf(MyCustomServiceException.class)
.hasMessageContaining("{'Error':'invalid request'}");
}
private void setStubForInValidCheckCall(HttpStatus httpStatus, String body) {
var response= aResponse().withStatus(httpStatus.value());
if(body!=null){
response=response.withBody(body);
}
stubFor(
get(urlPathMatching("/targetCompliance"))
.willReturn(response));
}
However, the second test in which the response is 500 but there's no body (or if it's an empty string), fails with "java.lang.IllegalStateException: Only one connection receive subscriber allowed.
#Test
void shouldThrowCustomExceptionWhenServiceReturns500ServerErrorWithNoBody() {
setStubForInValidCheckCall(HttpStatus.INTERNAL_SERVER_ERROR,null);
Throwable thrown =
catchThrowable(() -> myClient.performComplianceCheck(getCompany()));
assertThat(thrown)
.isInstanceOf(MyCustomServiceException.class)
.hasMessageContaining("service returned status 500");
}
I am struggling to understand why this happens, and how to fix it..
is it "normal" ? or am I missing something obvious (is it a problem with my test ?) ?
I have found a workaround, but it doesn't feel "webFlux-y" at all, and I still don't understand why the Only one connection receive subscriber allowed was happening :
try {
ResponseEntity<String> responseEntity =
webClient.get()
.uri(targetServiceUri)
.retrieve()
.toEntity(String.class)
.block();
}
catch (WebClientException e) {
if(e instanceof InternalServerError){
var internalServerError=(InternalServerError) e;
if(internalServerError.getStatusCode().is5xxServerError()){
var respBody=internalServerError.getResponseBodyAsString();
if(StringUtils.isEmpty(respBody)){
respBody=MY_STANDARD_MESSAGE +internalServerError.getRawStatusCode() ;
}
throw new MyCustomServiceException(respBody);
}
}
}
I have a piece of code that calls an external service. And I wanted to map error from this service. I tried to do that this way:
public Mono<Void> patch(String userId, Payload payload) {
return Mono.just(payload)
.flatMap(it -> client.patch(userId, PatchRequest.buildRequest(payload, userId))
.onErrorMap(throwable -> GeneralActionException.ofFailedSetup()));
}
But when I mocked the client to return RuntimeException
Mockito.when(client.patch(any(), any())).thenThrow(new RuntimeException());
It turned out my test:
StepVerifier.create(sut.patch(userId, payload))
.verifyError(GeneralActionException.class);
failed, because the returned error was RuntimeException:
However when I change the code a little, just like that:
public Mono<Void> patch(String userId, Payload payload) {
return Mono.just(payload)
.flatMap(it -> client.patch(userId, PatchRequest.buildRequest(payload, userId)))
.onErrorMap(throwable -> GeneralActionException.ofFailedSetup());
}
It turned out the test succeeded. The question is why? Because I don't understand why it works differently in both cases and especially why in the first example when error mapping is inside flatMap it doesn't map it to GeneralException?
Ok, I solved it. I mocked client wrongly.
Instead of:
Mockito.when(client.patch(any(), any())).thenThrow(new RuntimeException());
it should be:
Mockito.when(client.patch(any(), any())).thenReturn(Mono.error(RuntimeException::new));
as the client returns Mono<Void>
I am trying to replace the existing client code with RestTemplate with a WebClient. For that reason, most of the calls need to be blocking, so that the main portion of the application does not need to change. When it comes to error handling this poses a bit of a problem. There are several cases that have to be covered:
In a successful case the response contains a JSON object of type A
In an error case (HTTP status 4xx or 5xx) the response may contain a JSON object of type B
On certain requests with response status 404 I need to return an empty List matching the type of a successful response
In order to produce the correct error (Exception) the error response needs to be considered. So far I am unable to get my hands on the error body.
I am using this RestController method to produce the error response:
#GetMapping("/error/404")
#ResponseStatus(HttpStatus.NOT_FOUND)
public ResponseEntity error404() {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse());
}
With this response object:
public class ErrorResponse {
private String message = "Error message";
public String getMessage() {
return message;
}
}
The WebClient is defined as follows:
WebClient.builder()
.baseUrl("http://localhost:8081")
.clientConnector(connector)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
With the connector being of type CloseableHttpAsyncClient (Apache Http client5).
From my test application I make the call like this:
public String get(int httpStatus) {
try {
return webClient.get()
.uri("/error/" + httpStatus)
.retrieve()
.onStatus(HttpStatus::isError, clientResponse -> {
clientResponse.bodyToMono(String.class).flatMap(responseBody -> {
log.error("Body from within flatMap within onStatus: {}", responseBody);
return Mono.just(responseBody);
});
return Mono.error(new RuntimeException("Resolved!"));
})
.bodyToMono(String.class)
.flatMap(clientResponse -> {
log.warn("Body from within flatMap: {}", clientResponse);
return Mono.just(clientResponse);
})
.block();
} catch (Exception ex) {
log.error("Caught Error: ", ex);
return ex.getMessage();
}
}
What I get is the RuntimeException from the onStatus return and of course the caught exception in the end.
I am missing the processing from the bodyToMono from within the onStatus. My suspicion is that this is not executed due to the blocking nature, as the response body is dealt with the bodyToMono after the onStatus.
When commenting out the onStatus I would expect that we log the warning in the flatMap, which does not happen either.
In the end I would like to define the handling of errors as a filter so that the code does not need to be repeated on every call, but I need to get the error response body, so that the exception can be populated with the correct data.
How can I retrieve the error response in a synchronous WebClient call?
This question is similar to Spring Webflux : Webclient : Get body on error, which has no accepted answer and some of the suggested approaches use methods that are no deprecated.
Here is one approach to handle error responses:
use onStatus to capture error http status
deserialize error response clientResponse.bodyToMono(ErrorResponse.class)
generate new error signal based on the error response Mono.error(new RuntimeException(error.getMessage())). Example uses RuntimeException but I would suggest to use custom exception to simplify error handling downstream.
webClient.get()
.uri("/error/" + httpStatus)
.retrieve()
.onStatus(HttpStatus::isError, clientResponse ->
clientResponse.bodyToMono(ErrorResponse.class)
.flatMap(error ->
Mono.error(new RuntimeException(error.getMessage()))
)
)
.bodyToMono(Response.class)
You don't really need try-catch. If you block the above code would return Response in case of the non-error response and throws exception with custom message in case of error response.
Update
Here is a full test using WireMock
class WebClientErrorHandlingTest {
private WireMockServer wireMockServer;
#BeforeEach
void init() {
wireMockServer = new WireMockServer(wireMockConfig().dynamicPort());
wireMockServer.start();
WireMock.configureFor(wireMockServer.port());
}
#Test
void test() {
stubFor(post("/test")
.willReturn(aResponse()
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withStatus(400)
.withBody("{\"message\":\"Request error\",\"errorCode\":\"10000\"}")
)
);
WebClient webClient = WebClient.create("http://localhost:" + wireMockServer.port());
Mono<Response> request = webClient.post()
.uri("/test")
.retrieve()
.onStatus(HttpStatus::isError, clientResponse ->
clientResponse.bodyToMono(ErrorResponse.class)
.flatMap(error ->
Mono.error(new RuntimeException(error.getMessage() + ": " + error.getErrorCode()))
)
)
.bodyToMono(Response.class);
RuntimeException ex = assertThrows(RuntimeException.class, () -> request.block());
assertEquals("Request error: 10000", ex.getMessage());
}
#Data
private static class ErrorResponse {
private String message;
private int errorCode;
}
#Data
private static class Response {
private String result;
}
}
I have a method that when the user clicks a button, it calls another method and invokes a WebClient, etc.
Basically, the WebClient throws an exception but the calling method is not capturing it.
For example, here is a shortened version:
Calling Method
try {
service.getMessageAsync(messageDto, results -> getUI().ifPresent(ui -> ui.access(() -> {
.... code here ....
})));
} catch (RuntimeException e) {
logger.error("RuntimeException (startEtl):");
logger.error("\tException: {0}", e);
}
Service Method
public void getMessageAsync(MessageDto messageDto, AsyncRestCallback<MessageDto> callback)
throws RuntimeException {
... code omitted ...
WebClient.RequestHeadersSpec<?> spec = WebClient
.create()
.post()
.uri(URI)
.body(Mono.just(messageDto), MessageDto.class)
.headers(httpHeaders -> httpHeaders.setBasicAuth(username, password));
spec
.retrieve()
.onStatus(HttpStatus::is4xxClientError, error -> Mono.error(new RuntimeException("4xx Client Error.")))
.onStatus(HttpStatus::is5xxServerError, error -> Mono.error(new RuntimeException("5xx Server Error.")))
.toEntityList(MessageDto.class).subscribe(result -> {
callback.operationFinished(message.get(0));
});
}
So, in my logs, I can clearly see the 4xx Client Error. entries. I'm not sure how they are in the logs.
But I do NOT see the RuntimeException (startEtl): in the calling method.
I'm sure I've overlooked something obvious but I can't seem to find it.
So why is the calling exception handling not working as I expect?
Is it because onStatus is somehow handling the exception internally?
Thanks
I have a Feign client with a method returning the feign.Response class. When another service throws an exception, feign puts an exception message on response body and puts status, but my service does not throw an exception. Can I throw an exception based on what I received in response like when I use ResponseEntity.
Feign client
#FeignClient(name = "ms-filestorage")
#RequestMapping(value = "/files", produces = "application/json")
public interface FileStorageApi {
#GetMapping(value = "/{id}")
Response getFileById(#PathVariable String id);
}
Usage of client
#Override
public Response getFileFromStorage(String fileId) {
Response fileStorageResponse = fileStorageApi.getFileById(fileId);
// NOW I USE THIS WAY FOR CHECKING RESPONSE BUT IT DOESN'T LOOK GOOD
//if (fileStorageResponse.status() != HttpStatus.OK.value()) {
// throw new OsagoServiceException();
//}
return fileStorageResponse;
}
Usually, if a Feign client call receives an error response from the API it is calling, it throws a FeignException.
This can be caught in a try / catch block (or a Feign ErrorDecoder if you want to be more sophisticated, but that's another post).
However, this is not the case if you map the error response into a Feign.Response return type - see this Github issue.
Instead of returning Feign.Response from getFileFromStorage(), you should create a custom Java object to hold the response, and you will then have access to the FeignException which you can handle as you wish.
Note that if you don't need access to the data that is returned from the API you are calling, changing the return type to void will also resolve this issue.