Consistent handling of 4xx/5xx status codes in WebClient

This commit changes the handling of 4xx/5xx status codes in the
WebClient to the following simple rule: if there is no way for the user
to get the response status code, then a WebClientException is returned.
If there is a way to get to the status code, then we do not return an
exception.

Issue: SPR-15486
This commit is contained in:
Arjen Poutsma 2017-04-26 17:33:21 +02:00
parent 0e7d6fc4d1
commit 4a8c99c9ce
6 changed files with 93 additions and 83 deletions

View File

@ -59,9 +59,7 @@ public interface ClientResponse {
MultiValueMap<String, ResponseCookie> cookies(); MultiValueMap<String, ResponseCookie> cookies();
/** /**
* Extract the body with the given {@code BodyExtractor}. Unlike {@link #bodyToMono(Class)} and * Extract the body with the given {@code BodyExtractor}.
* {@link #bodyToFlux(Class)}; this method does not check for a 4xx or 5xx status code before
* extracting the body.
* @param extractor the {@code BodyExtractor} that reads from the response * @param extractor the {@code BodyExtractor} that reads from the response
* @param <T> the type of the body returned * @param <T> the type of the body returned
* @return the extracted body * @return the extracted body
@ -69,22 +67,18 @@ public interface ClientResponse {
<T> T body(BodyExtractor<T, ? super ClientHttpResponse> extractor); <T> T body(BodyExtractor<T, ? super ClientHttpResponse> extractor);
/** /**
* Extract the body to a {@code Mono}. If the response has status code 4xx or 5xx, the * Extract the body to a {@code Mono}.
* {@code Mono} will contain a {@link WebClientException}.
* @param elementClass the class of element in the {@code Mono} * @param elementClass the class of element in the {@code Mono}
* @param <T> the element type * @param <T> the element type
* @return a mono containing the body, or a {@link WebClientException} if the status code is * @return a mono containing the body of the given type {@code T}
* 4xx or 5xx
*/ */
<T> Mono<T> bodyToMono(Class<? extends T> elementClass); <T> Mono<T> bodyToMono(Class<? extends T> elementClass);
/** /**
* Extract the body to a {@code Flux}. If the response has status code 4xx or 5xx, the * Extract the body to a {@code Flux}.
* {@code Flux} will contain a {@link WebClientException}.
* @param elementClass the class of element in the {@code Flux} * @param elementClass the class of element in the {@code Flux}
* @param <T> the element type * @param <T> the element type
* @return a flux containing the body, or a {@link WebClientException} if the status code is * @return a flux containing the body of the given type {@code T}
* 4xx or 5xx
*/ */
<T> Flux<T> bodyToFlux(Class<? extends T> elementClass); <T> Flux<T> bodyToFlux(Class<? extends T> elementClass);

View File

@ -21,11 +21,9 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.OptionalLong; import java.util.OptionalLong;
import java.util.function.Function;
import java.util.function.Supplier; import java.util.function.Supplier;
import java.util.stream.Stream; import java.util.stream.Stream;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@ -99,28 +97,12 @@ class DefaultClientResponse implements ClientResponse {
@Override @Override
public <T> Mono<T> bodyToMono(Class<? extends T> elementClass) { public <T> Mono<T> bodyToMono(Class<? extends T> elementClass) {
return bodyToPublisher(BodyExtractors.toMono(elementClass), Mono::error); return body(BodyExtractors.toMono(elementClass));
} }
@Override @Override
public <T> Flux<T> bodyToFlux(Class<? extends T> elementClass) { public <T> Flux<T> bodyToFlux(Class<? extends T> elementClass) {
return bodyToPublisher(BodyExtractors.toFlux(elementClass), Flux::error); return body(BodyExtractors.toFlux(elementClass));
}
private <T extends Publisher<?>> T bodyToPublisher(
BodyExtractor<T, ? super ClientHttpResponse> extractor,
Function<WebClientException, T> errorFunction) {
HttpStatus status = statusCode();
if (status.is4xxClientError() || status.is5xxServerError()) {
WebClientException ex = new WebClientException(
"ClientResponse has erroneous status code: " + status.value() +
" " + status.getReasonPhrase());
return errorFunction.apply(ex);
}
else {
return body(extractor);
}
} }

View File

@ -32,13 +32,17 @@ import reactor.core.publisher.Mono;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.http.client.reactive.ClientHttpRequest; import org.springframework.http.client.reactive.ClientHttpRequest;
import org.springframework.http.client.reactive.ClientHttpResponse;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.BodyExtractor;
import org.springframework.web.reactive.function.BodyExtractors;
import org.springframework.web.reactive.function.BodyInserter; import org.springframework.web.reactive.function.BodyInserter;
import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.util.DefaultUriBuilderFactory; import org.springframework.web.util.DefaultUriBuilderFactory;
@ -350,14 +354,35 @@ class DefaultWebClient implements WebClient {
@Override @Override
public <T> Mono<T> bodyToMono(Class<T> bodyType) { public <T> Mono<T> bodyToMono(Class<T> bodyType) {
return this.responseMono.flatMap(clientResponse -> clientResponse.bodyToMono(bodyType)); return this.responseMono.flatMap(
response -> bodyToPublisher(response, BodyExtractors.toMono(bodyType),
Mono::error));
} }
@Override @Override
public <T> Flux<T> bodyToFlux(Class<T> elementType) { public <T> Flux<T> bodyToFlux(Class<T> elementType) {
return this.responseMono.flatMapMany(clientResponse -> clientResponse.bodyToFlux(elementType)); return this.responseMono.flatMapMany(
response -> bodyToPublisher(response, BodyExtractors.toFlux(elementType),
Flux::error));
} }
private <T extends Publisher<?>> T bodyToPublisher(ClientResponse response,
BodyExtractor<T, ? super ClientHttpResponse> extractor,
Function<WebClientException, T> errorFunction) {
HttpStatus status = response.statusCode();
if (status.is4xxClientError() || status.is5xxServerError()) {
WebClientException ex = new WebClientException(
"ClientResponse has erroneous status code: " + status.value() +
" " + status.getReasonPhrase());
return errorFunction.apply(ex);
}
else {
return response.body(extractor);
}
}
@Override @Override
public <T> Mono<ResponseEntity<T>> toEntity(Class<T> bodyType) { public <T> Mono<ResponseEntity<T>> toEntity(Class<T> bodyType) {
return this.responseMono.flatMap(response -> return this.responseMono.flatMap(response ->

View File

@ -477,42 +477,46 @@ public interface WebClient {
interface ResponseSpec { interface ResponseSpec {
/** /**
* Extract the response body to an Object of type {@code <T>} by * Extract the body to a {@code Mono}. If the response has status code 4xx or 5xx, the
* invoking {@link ClientResponse#bodyToMono(Class)}. * {@code Mono} will contain a {@link WebClientException}.
* *
* @param bodyType the expected response body type * @param bodyType the expected response body type
* @param <T> response body type * @param <T> response body type
* @return {@code Mono} with the result * @return a mono containing the body, or a {@link WebClientException} if the status code is
* 4xx or 5xx
*/ */
<T> Mono<T> bodyToMono(Class<T> bodyType); <T> Mono<T> bodyToMono(Class<T> bodyType);
/** /**
* Extract the response body to a stream of Objects of type {@code <T>} * Extract the body to a {@code Flux}. If the response has status code 4xx or 5xx, the
* by invoking {@link ClientResponse#bodyToFlux(Class)}. * {@code Flux} will contain a {@link WebClientException}.
* *
* @param elementType the type of element in the response * @param elementType the type of element in the response
* @param <T> the type of elements in the response * @param <T> the type of elements in the response
* @return the body of the response * @return a flux containing the body, or a {@link WebClientException} if the status code is
* 4xx or 5xx
*/ */
<T> Flux<T> bodyToFlux(Class<T> elementType); <T> Flux<T> bodyToFlux(Class<T> elementType);
/** /**
* A variant of {@link #bodyToMono(Class)} that also provides access to * Returns the response as a delayed {@code ResponseEntity}. Unlike
* the response status and headers. * {@link #bodyToMono(Class)} and {@link #bodyToFlux(Class)}, this method does not check
* for a 4xx or 5xx status code before extracting the body.
* *
* @param bodyType the expected response body type * @param bodyType the expected response body type
* @param <T> response body type * @param <T> response body type
* @return {@code Mono} with the result * @return {@code Mono} with the {@code ResponseEntity}
*/ */
<T> Mono<ResponseEntity<T>> toEntity(Class<T> bodyType); <T> Mono<ResponseEntity<T>> toEntity(Class<T> bodyType);
/** /**
* A variant of {@link #bodyToFlux(Class)} collected via * Returns the response as a delayed list of {@code ResponseEntity}s. Unlike
* {@link Flux#collectList()} and wrapped in {@code ResponseEntity}. * {@link #bodyToMono(Class)} and {@link #bodyToFlux(Class)}, this method does not check
* for a 4xx or 5xx status code before extracting the body.
* *
* @param elementType the expected response body list element type * @param elementType the expected response body list element type
* @param <T> the type of elements in the list * @param <T> the type of elements in the list
* @return {@code Mono} with the result * @return {@code Mono} with the list of {@code ResponseEntity}s
*/ */
<T> Mono<ResponseEntity<List<T>>> toEntityList(Class<T> elementType); <T> Mono<ResponseEntity<List<T>>> toEntityList(Class<T> elementType);

View File

@ -29,7 +29,6 @@ import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import org.springframework.core.codec.StringDecoder; import org.springframework.core.codec.StringDecoder;
import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBuffer;
@ -48,7 +47,7 @@ import org.springframework.util.MultiValueMap;
import static org.junit.Assert.*; import static org.junit.Assert.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
import static org.springframework.web.reactive.function.BodyExtractors.*; import static org.springframework.web.reactive.function.BodyExtractors.toMono;
/** /**
* @author Arjen Poutsma * @author Arjen Poutsma
@ -151,24 +150,6 @@ public class DefaultClientResponseTests {
assertEquals("foo", resultMono.block()); assertEquals("foo", resultMono.block());
} }
@Test
public void bodyToMonoError() throws Exception {
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.TEXT_PLAIN);
when(mockResponse.getHeaders()).thenReturn(httpHeaders);
when(mockResponse.getStatusCode()).thenReturn(HttpStatus.NOT_FOUND);
Set<HttpMessageReader<?>> messageReaders = Collections
.singleton(new DecoderHttpMessageReader<>(StringDecoder.allMimeTypes(true)));
when(mockExchangeStrategies.messageReaders()).thenReturn(messageReaders::stream);
Mono<String> resultMono = defaultClientResponse.bodyToMono(String.class);
StepVerifier.create(resultMono)
.expectError(WebClientException.class)
.verify();
}
@Test @Test
public void bodyToFlux() throws Exception { public void bodyToFlux() throws Exception {
DefaultDataBufferFactory factory = new DefaultDataBufferFactory(); DefaultDataBufferFactory factory = new DefaultDataBufferFactory();
@ -191,21 +172,4 @@ public class DefaultClientResponseTests {
assertEquals(Collections.singletonList("foo"), result.block()); assertEquals(Collections.singletonList("foo"), result.block());
} }
@Test
public void bodyToFluxError() throws Exception {
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.TEXT_PLAIN);
when(mockResponse.getHeaders()).thenReturn(httpHeaders);
when(mockResponse.getStatusCode()).thenReturn(HttpStatus.INTERNAL_SERVER_ERROR);
Set<HttpMessageReader<?>> messageReaders = Collections
.singleton(new DecoderHttpMessageReader<>(StringDecoder.allMimeTypes(true)));
when(mockExchangeStrategies.messageReaders()).thenReturn(messageReaders::stream);
Flux<String> resultFlux = defaultClientResponse.bodyToFlux(String.class);
StepVerifier.create(resultFlux)
.expectError(WebClientException.class)
.verify();
}
} }

View File

@ -334,7 +334,7 @@ public class WebClientIntegrationTests {
} }
@Test @Test
public void notFound() throws Exception { public void exchangeNotFound() throws Exception {
this.server.enqueue(new MockResponse().setResponseCode(404) this.server.enqueue(new MockResponse().setResponseCode(404)
.setHeader("Content-Type", "text/plain").setBody("Not Found")); .setHeader("Content-Type", "text/plain").setBody("Not Found"));
@ -351,6 +351,47 @@ public class WebClientIntegrationTests {
Assert.assertEquals("/greeting?name=Spring", recordedRequest.getPath()); Assert.assertEquals("/greeting?name=Spring", recordedRequest.getPath());
} }
@Test
public void retrieveBodyToMonoNotFound() throws Exception {
this.server.enqueue(new MockResponse().setResponseCode(404)
.setHeader("Content-Type", "text/plain").setBody("Not Found"));
Mono<String> result = this.webClient.get()
.uri("/greeting?name=Spring")
.retrieve()
.bodyToMono(String.class);
StepVerifier.create(result)
.expectError(WebClientException.class)
.verify(Duration.ofSeconds(3));
RecordedRequest recordedRequest = server.takeRequest();
Assert.assertEquals(1, server.getRequestCount());
Assert.assertEquals("*/*", recordedRequest.getHeader(HttpHeaders.ACCEPT));
Assert.assertEquals("/greeting?name=Spring", recordedRequest.getPath());
}
@Test
public void retrieveToEntityNotFound() throws Exception {
this.server.enqueue(new MockResponse().setResponseCode(404)
.setHeader("Content-Type", "text/plain").setBody("Not Found"));
Mono<ResponseEntity<String>> result = this.webClient.get()
.uri("/greeting?name=Spring")
.retrieve()
.toEntity(String.class);
StepVerifier.create(result)
.consumeNextWith(response -> assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()))
.expectComplete()
.verify(Duration.ofSeconds(3));
RecordedRequest recordedRequest = server.takeRequest();
Assert.assertEquals(1, server.getRequestCount());
Assert.assertEquals("*/*", recordedRequest.getHeader(HttpHeaders.ACCEPT));
Assert.assertEquals("/greeting?name=Spring", recordedRequest.getPath());
}
@Test @Test
public void buildFilter() throws Exception { public void buildFilter() throws Exception {
this.server.enqueue(new MockResponse().setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); this.server.enqueue(new MockResponse().setHeader("Content-Type", "text/plain").setBody("Hello Spring!"));