Wrap exceptions in WebClient

This commit makes sure that exceptions emitted by WebClient are wrapped
by WebClientExceptions:

- Exceptions emitted by the ClientHttpConnector are wrapped in a new
  WebClientRequestException.

- Exceptions emitted after a response is received are wrapped in a
  WebClientResponseException

Closes gh-23842
This commit is contained in:
Arjen Poutsma 2019-11-18 11:10:33 +01:00
parent 4dfecde694
commit 74f64c4e3b
7 changed files with 131 additions and 8 deletions

View File

@ -55,6 +55,9 @@ import org.springframework.web.reactive.function.BodyExtractors;
*/
class DefaultClientResponse implements ClientResponse {
private static final byte[] EMPTY = new byte[0];
private final ClientHttpResponse response;
private final Headers headers;
@ -200,7 +203,8 @@ class DefaultClientResponse implements ClientResponse {
DataBufferUtils.release(dataBuffer);
return bytes;
})
.defaultIfEmpty(new byte[0])
.defaultIfEmpty(EMPTY)
.onErrorReturn(IllegalStateException.class::isInstance, EMPTY)
.map(bodyBytes -> {
HttpRequest request = this.requestSupplier.get();
Charset charset = headers().contentType()

View File

@ -488,11 +488,13 @@ class DefaultWebClient implements WebClient {
private <T> Mono<T> handleBodyMono(ClientResponse response, Mono<T> bodyPublisher) {
Mono<T> result = statusHandlers(response);
Mono<T> wrappedExceptions = bodyPublisher.onErrorResume(WebClientUtils::shouldWrapException,
t -> wrapException(t, response));
if (result != null) {
return result.switchIfEmpty(bodyPublisher);
return result.switchIfEmpty(wrappedExceptions);
}
else {
return bodyPublisher;
return wrappedExceptions;
}
}
@ -510,11 +512,13 @@ class DefaultWebClient implements WebClient {
private <T> Publisher<T> handleBodyFlux(ClientResponse response, Flux<T> bodyPublisher) {
Mono<T> result = statusHandlers(response);
Flux<T> wrappedExceptions = bodyPublisher.onErrorResume(WebClientUtils::shouldWrapException,
t -> wrapException(t, response));
if (result != null) {
return result.flux().switchIfEmpty(bodyPublisher);
return result.flux().switchIfEmpty(wrappedExceptions);
}
else {
return bodyPublisher;
return wrappedExceptions;
}
}
@ -555,6 +559,12 @@ class DefaultWebClient implements WebClient {
return result.checkpoint(description);
}
private <T> Mono<T> wrapException(Throwable throwable, ClientResponse response) {
return response.createException()
.map(responseException -> responseException.initCause(throwable))
.flatMap(Mono::error);
}
@Override
public <T> Mono<ResponseEntity<T>> toEntity(Class<T> bodyClass) {
return this.responseMono.flatMap(response ->

View File

@ -104,6 +104,7 @@ public abstract class ExchangeFunctions {
.connect(httpMethod, url, httpRequest -> clientRequest.writeTo(httpRequest, this.strategies))
.doOnRequest(n -> logRequest(clientRequest))
.doOnCancel(() -> logger.debug(logPrefix + "Cancel signal (to close connection)"))
.onErrorResume(WebClientUtils::shouldWrapException, t -> wrapException(t, clientRequest))
.map(httpResponse -> {
logResponse(httpResponse, logPrefix);
return new DefaultClientResponse(
@ -132,6 +133,10 @@ public abstract class ExchangeFunctions {
return this.enableLoggingRequestDetails ? headers.toString() : headers.isEmpty() ? "{}" : "{masked}";
}
private <T> Mono<T> wrapException(Throwable t, ClientRequest r) {
return Mono.error(() -> new WebClientRequestException(t, r.method(), r.url(), r.headers()));
}
private HttpRequest createRequest(ClientRequest request) {
return new HttpRequest() {

View File

@ -0,0 +1,74 @@
/*
* Copyright 2002-2020 the original author or authors.
*
* 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 org.springframework.web.reactive.function.client;
import java.net.URI;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
/**
* Exceptions that contain actual HTTP request data.
*
* @author Arjen Poutsma
* @since 5.3
*/
public class WebClientRequestException extends WebClientException {
private static final long serialVersionUID = -5139991985321385005L;
private final HttpMethod method;
private final URI uri;
private final HttpHeaders headers;
/**
* Constructor for throwable.
*/
public WebClientRequestException(Throwable ex, HttpMethod method, URI uri, HttpHeaders headers) {
super(ex.getMessage(), ex);
this.method = method;
this.uri = uri;
this.headers = headers;
}
/**
* Return the HTTP request method.
*/
public HttpMethod getMethod() {
return this.method;
}
/**
* Return the request URI.
*/
public URI getUri() {
return this.uri;
}
/**
* Return the HTTP request headers.
*/
public HttpHeaders getHeaders() {
return this.headers;
}
}

View File

@ -22,6 +22,7 @@ import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.core.codec.CodecException;
import org.springframework.http.ResponseEntity;
/**
@ -56,4 +57,10 @@ abstract class WebClientUtils {
.body(list));
}
/**
* Indicates whether the given exception should be wrapped.
*/
public static boolean shouldWrapException(Throwable t) {
return !(t instanceof WebClientException) && !(t instanceof CodecException);
}
}

View File

@ -40,7 +40,6 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.http.client.reactive.ReactorResourceFactory;
import org.springframework.web.reactive.function.UnsupportedMediaTypeException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS;
@ -127,7 +126,7 @@ class WebClientDataBufferAllocatingTests extends AbstractDataBufferAllocatingTes
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, String>>() {});
StepVerifier.create(mono).expectError(UnsupportedMediaTypeException.class).verify(Duration.ofSeconds(3));
StepVerifier.create(mono).expectError(WebClientResponseException.class).verify(Duration.ofSeconds(3));
assertThat(this.server.getRequestCount()).isEqualTo(1);
}

View File

@ -1013,7 +1013,12 @@ class WebClientIntegrationTests {
Mono<ClientResponse> responseMono = WebClient.builder().build().get().uri(uri).exchange();
StepVerifier.create(responseMono)
.expectErrorMessage("URI is not absolute: " + uri)
.expectErrorSatisfies(throwable -> {
assertThat(throwable).isInstanceOf(WebClientRequestException.class);
WebClientRequestException ex = (WebClientRequestException) throwable;
assertThat(ex.getMethod()).isEqualTo(HttpMethod.GET);
assertThat(ex.getUri()).isEqualTo(URI.create(uri));
})
.verify(Duration.ofSeconds(5));
}
@ -1126,6 +1131,25 @@ class WebClientIntegrationTests {
expectRequestCount(1);
}
@ParameterizedWebClientTest
void invalidDomain(ClientHttpConnector connector) {
startServer(connector);
String url = "http://example.invalid";
Mono<ClientResponse> result = this.webClient.get().
uri(url)
.exchange();
StepVerifier.create(result)
.expectErrorSatisfies(throwable -> {
assertThat(throwable).isInstanceOf(WebClientRequestException.class);
WebClientRequestException ex = (WebClientRequestException) throwable;
assertThat(ex.getMethod()).isEqualTo(HttpMethod.GET);
assertThat(ex.getUri()).isEqualTo(URI.create(url));
})
.verify();
}
private void prepareResponse(Consumer<MockResponse> consumer) {
MockResponse response = new MockResponse();