Skip to content

Commit 4a8c99c

Browse files
committed
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
1 parent 0e7d6fc commit 4a8c99c

File tree

6 files changed

+93
-83
lines changed

6 files changed

+93
-83
lines changed

spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ClientResponse.java

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,32 +59,26 @@ public interface ClientResponse {
5959
MultiValueMap<String, ResponseCookie> cookies();
6060

6161
/**
62-
* Extract the body with the given {@code BodyExtractor}. Unlike {@link #bodyToMono(Class)} and
63-
* {@link #bodyToFlux(Class)}; this method does not check for a 4xx or 5xx status code before
64-
* extracting the body.
62+
* Extract the body with the given {@code BodyExtractor}.
6563
* @param extractor the {@code BodyExtractor} that reads from the response
6664
* @param <T> the type of the body returned
6765
* @return the extracted body
6866
*/
6967
<T> T body(BodyExtractor<T, ? super ClientHttpResponse> extractor);
7068

7169
/**
72-
* Extract the body to a {@code Mono}. If the response has status code 4xx or 5xx, the
73-
* {@code Mono} will contain a {@link WebClientException}.
70+
* Extract the body to a {@code Mono}.
7471
* @param elementClass the class of element in the {@code Mono}
7572
* @param <T> the element type
76-
* @return a mono containing the body, or a {@link WebClientException} if the status code is
77-
* 4xx or 5xx
73+
* @return a mono containing the body of the given type {@code T}
7874
*/
7975
<T> Mono<T> bodyToMono(Class<? extends T> elementClass);
8076

8177
/**
82-
* Extract the body to a {@code Flux}. If the response has status code 4xx or 5xx, the
83-
* {@code Flux} will contain a {@link WebClientException}.
78+
* Extract the body to a {@code Flux}.
8479
* @param elementClass the class of element in the {@code Flux}
8580
* @param <T> the element type
86-
* @return a flux containing the body, or a {@link WebClientException} if the status code is
87-
* 4xx or 5xx
81+
* @return a flux containing the body of the given type {@code T}
8882
*/
8983
<T> Flux<T> bodyToFlux(Class<? extends T> elementClass);
9084

spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientResponse.java

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,9 @@
2121
import java.util.Map;
2222
import java.util.Optional;
2323
import java.util.OptionalLong;
24-
import java.util.function.Function;
2524
import java.util.function.Supplier;
2625
import java.util.stream.Stream;
2726

28-
import org.reactivestreams.Publisher;
2927
import reactor.core.publisher.Flux;
3028
import reactor.core.publisher.Mono;
3129

@@ -99,28 +97,12 @@ public Map<String, Object> hints() {
9997

10098
@Override
10199
public <T> Mono<T> bodyToMono(Class<? extends T> elementClass) {
102-
return bodyToPublisher(BodyExtractors.toMono(elementClass), Mono::error);
100+
return body(BodyExtractors.toMono(elementClass));
103101
}
104102

105103
@Override
106104
public <T> Flux<T> bodyToFlux(Class<? extends T> elementClass) {
107-
return bodyToPublisher(BodyExtractors.toFlux(elementClass), Flux::error);
108-
}
109-
110-
private <T extends Publisher<?>> T bodyToPublisher(
111-
BodyExtractor<T, ? super ClientHttpResponse> extractor,
112-
Function<WebClientException, T> errorFunction) {
113-
114-
HttpStatus status = statusCode();
115-
if (status.is4xxClientError() || status.is5xxServerError()) {
116-
WebClientException ex = new WebClientException(
117-
"ClientResponse has erroneous status code: " + status.value() +
118-
" " + status.getReasonPhrase());
119-
return errorFunction.apply(ex);
120-
}
121-
else {
122-
return body(extractor);
123-
}
105+
return body(BodyExtractors.toFlux(elementClass));
124106
}
125107

126108

spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,17 @@
3232

3333
import org.springframework.http.HttpHeaders;
3434
import org.springframework.http.HttpMethod;
35+
import org.springframework.http.HttpStatus;
3536
import org.springframework.http.MediaType;
3637
import org.springframework.http.ResponseEntity;
3738
import org.springframework.http.client.reactive.ClientHttpRequest;
39+
import org.springframework.http.client.reactive.ClientHttpResponse;
3840
import org.springframework.util.Assert;
3941
import org.springframework.util.CollectionUtils;
4042
import org.springframework.util.LinkedMultiValueMap;
4143
import org.springframework.util.MultiValueMap;
44+
import org.springframework.web.reactive.function.BodyExtractor;
45+
import org.springframework.web.reactive.function.BodyExtractors;
4246
import org.springframework.web.reactive.function.BodyInserter;
4347
import org.springframework.web.reactive.function.BodyInserters;
4448
import org.springframework.web.util.DefaultUriBuilderFactory;
@@ -350,14 +354,35 @@ private static class DefaultResponseSpec implements ResponseSpec {
350354

351355
@Override
352356
public <T> Mono<T> bodyToMono(Class<T> bodyType) {
353-
return this.responseMono.flatMap(clientResponse -> clientResponse.bodyToMono(bodyType));
357+
return this.responseMono.flatMap(
358+
response -> bodyToPublisher(response, BodyExtractors.toMono(bodyType),
359+
Mono::error));
354360
}
355361

356362
@Override
357363
public <T> Flux<T> bodyToFlux(Class<T> elementType) {
358-
return this.responseMono.flatMapMany(clientResponse -> clientResponse.bodyToFlux(elementType));
364+
return this.responseMono.flatMapMany(
365+
response -> bodyToPublisher(response, BodyExtractors.toFlux(elementType),
366+
Flux::error));
359367
}
360368

369+
private <T extends Publisher<?>> T bodyToPublisher(ClientResponse response,
370+
BodyExtractor<T, ? super ClientHttpResponse> extractor,
371+
Function<WebClientException, T> errorFunction) {
372+
373+
HttpStatus status = response.statusCode();
374+
if (status.is4xxClientError() || status.is5xxServerError()) {
375+
WebClientException ex = new WebClientException(
376+
"ClientResponse has erroneous status code: " + status.value() +
377+
" " + status.getReasonPhrase());
378+
return errorFunction.apply(ex);
379+
}
380+
else {
381+
return response.body(extractor);
382+
}
383+
}
384+
385+
361386
@Override
362387
public <T> Mono<ResponseEntity<T>> toEntity(Class<T> bodyType) {
363388
return this.responseMono.flatMap(response ->

spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -477,42 +477,46 @@ interface RequestBodySpec extends RequestHeadersSpec<RequestBodySpec> {
477477
interface ResponseSpec {
478478

479479
/**
480-
* Extract the response body to an Object of type {@code <T>} by
481-
* invoking {@link ClientResponse#bodyToMono(Class)}.
480+
* Extract the body to a {@code Mono}. If the response has status code 4xx or 5xx, the
481+
* {@code Mono} will contain a {@link WebClientException}.
482482
*
483483
* @param bodyType the expected response body type
484484
* @param <T> response body type
485-
* @return {@code Mono} with the result
485+
* @return a mono containing the body, or a {@link WebClientException} if the status code is
486+
* 4xx or 5xx
486487
*/
487488
<T> Mono<T> bodyToMono(Class<T> bodyType);
488489

489490
/**
490-
* Extract the response body to a stream of Objects of type {@code <T>}
491-
* by invoking {@link ClientResponse#bodyToFlux(Class)}.
491+
* Extract the body to a {@code Flux}. If the response has status code 4xx or 5xx, the
492+
* {@code Flux} will contain a {@link WebClientException}.
492493
*
493494
* @param elementType the type of element in the response
494495
* @param <T> the type of elements in the response
495-
* @return the body of the response
496+
* @return a flux containing the body, or a {@link WebClientException} if the status code is
497+
* 4xx or 5xx
496498
*/
497499
<T> Flux<T> bodyToFlux(Class<T> elementType);
498500

499501
/**
500-
* A variant of {@link #bodyToMono(Class)} that also provides access to
501-
* the response status and headers.
502+
* Returns the response as a delayed {@code ResponseEntity}. Unlike
503+
* {@link #bodyToMono(Class)} and {@link #bodyToFlux(Class)}, this method does not check
504+
* for a 4xx or 5xx status code before extracting the body.
502505
*
503506
* @param bodyType the expected response body type
504507
* @param <T> response body type
505-
* @return {@code Mono} with the result
508+
* @return {@code Mono} with the {@code ResponseEntity}
506509
*/
507510
<T> Mono<ResponseEntity<T>> toEntity(Class<T> bodyType);
508511

509512
/**
510-
* A variant of {@link #bodyToFlux(Class)} collected via
511-
* {@link Flux#collectList()} and wrapped in {@code ResponseEntity}.
513+
* Returns the response as a delayed list of {@code ResponseEntity}s. Unlike
514+
* {@link #bodyToMono(Class)} and {@link #bodyToFlux(Class)}, this method does not check
515+
* for a 4xx or 5xx status code before extracting the body.
512516
*
513517
* @param elementType the expected response body list element type
514518
* @param <T> the type of elements in the list
515-
* @return {@code Mono} with the result
519+
* @return {@code Mono} with the list of {@code ResponseEntity}s
516520
*/
517521
<T> Mono<ResponseEntity<List<T>>> toEntityList(Class<T> elementType);
518522

spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultClientResponseTests.java

Lines changed: 1 addition & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
import org.junit.Test;
3030
import reactor.core.publisher.Flux;
3131
import reactor.core.publisher.Mono;
32-
import reactor.test.StepVerifier;
3332

3433
import org.springframework.core.codec.StringDecoder;
3534
import org.springframework.core.io.buffer.DataBuffer;
@@ -48,7 +47,7 @@
4847

4948
import static org.junit.Assert.*;
5049
import static org.mockito.Mockito.*;
51-
import static org.springframework.web.reactive.function.BodyExtractors.*;
50+
import static org.springframework.web.reactive.function.BodyExtractors.toMono;
5251

5352
/**
5453
* @author Arjen Poutsma
@@ -151,24 +150,6 @@ public void bodyToMono() throws Exception {
151150
assertEquals("foo", resultMono.block());
152151
}
153152

154-
@Test
155-
public void bodyToMonoError() throws Exception {
156-
HttpHeaders httpHeaders = new HttpHeaders();
157-
httpHeaders.setContentType(MediaType.TEXT_PLAIN);
158-
when(mockResponse.getHeaders()).thenReturn(httpHeaders);
159-
when(mockResponse.getStatusCode()).thenReturn(HttpStatus.NOT_FOUND);
160-
161-
Set<HttpMessageReader<?>> messageReaders = Collections
162-
.singleton(new DecoderHttpMessageReader<>(StringDecoder.allMimeTypes(true)));
163-
when(mockExchangeStrategies.messageReaders()).thenReturn(messageReaders::stream);
164-
165-
Mono<String> resultMono = defaultClientResponse.bodyToMono(String.class);
166-
167-
StepVerifier.create(resultMono)
168-
.expectError(WebClientException.class)
169-
.verify();
170-
}
171-
172153
@Test
173154
public void bodyToFlux() throws Exception {
174155
DefaultDataBufferFactory factory = new DefaultDataBufferFactory();
@@ -191,21 +172,4 @@ public void bodyToFlux() throws Exception {
191172
assertEquals(Collections.singletonList("foo"), result.block());
192173
}
193174

194-
@Test
195-
public void bodyToFluxError() throws Exception {
196-
HttpHeaders httpHeaders = new HttpHeaders();
197-
httpHeaders.setContentType(MediaType.TEXT_PLAIN);
198-
when(mockResponse.getHeaders()).thenReturn(httpHeaders);
199-
when(mockResponse.getStatusCode()).thenReturn(HttpStatus.INTERNAL_SERVER_ERROR);
200-
201-
Set<HttpMessageReader<?>> messageReaders = Collections
202-
.singleton(new DecoderHttpMessageReader<>(StringDecoder.allMimeTypes(true)));
203-
when(mockExchangeStrategies.messageReaders()).thenReturn(messageReaders::stream);
204-
205-
Flux<String> resultFlux = defaultClientResponse.bodyToFlux(String.class);
206-
StepVerifier.create(resultFlux)
207-
.expectError(WebClientException.class)
208-
.verify();
209-
}
210-
211175
}

spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientIntegrationTests.java

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ public void cookies() throws Exception {
334334
}
335335

336336
@Test
337-
public void notFound() throws Exception {
337+
public void exchangeNotFound() throws Exception {
338338
this.server.enqueue(new MockResponse().setResponseCode(404)
339339
.setHeader("Content-Type", "text/plain").setBody("Not Found"));
340340

@@ -351,6 +351,47 @@ public void notFound() throws Exception {
351351
Assert.assertEquals("/greeting?name=Spring", recordedRequest.getPath());
352352
}
353353

354+
@Test
355+
public void retrieveBodyToMonoNotFound() throws Exception {
356+
this.server.enqueue(new MockResponse().setResponseCode(404)
357+
.setHeader("Content-Type", "text/plain").setBody("Not Found"));
358+
359+
Mono<String> result = this.webClient.get()
360+
.uri("/greeting?name=Spring")
361+
.retrieve()
362+
.bodyToMono(String.class);
363+
364+
StepVerifier.create(result)
365+
.expectError(WebClientException.class)
366+
.verify(Duration.ofSeconds(3));
367+
368+
RecordedRequest recordedRequest = server.takeRequest();
369+
Assert.assertEquals(1, server.getRequestCount());
370+
Assert.assertEquals("*/*", recordedRequest.getHeader(HttpHeaders.ACCEPT));
371+
Assert.assertEquals("/greeting?name=Spring", recordedRequest.getPath());
372+
}
373+
374+
@Test
375+
public void retrieveToEntityNotFound() throws Exception {
376+
this.server.enqueue(new MockResponse().setResponseCode(404)
377+
.setHeader("Content-Type", "text/plain").setBody("Not Found"));
378+
379+
Mono<ResponseEntity<String>> result = this.webClient.get()
380+
.uri("/greeting?name=Spring")
381+
.retrieve()
382+
.toEntity(String.class);
383+
384+
StepVerifier.create(result)
385+
.consumeNextWith(response -> assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()))
386+
.expectComplete()
387+
.verify(Duration.ofSeconds(3));
388+
389+
RecordedRequest recordedRequest = server.takeRequest();
390+
Assert.assertEquals(1, server.getRequestCount());
391+
Assert.assertEquals("*/*", recordedRequest.getHeader(HttpHeaders.ACCEPT));
392+
Assert.assertEquals("/greeting?name=Spring", recordedRequest.getPath());
393+
}
394+
354395
@Test
355396
public void buildFilter() throws Exception {
356397
this.server.enqueue(new MockResponse().setHeader("Content-Type", "text/plain").setBody("Hello Spring!"));

0 commit comments

Comments
 (0)