From 8d8b0e77db1b40575348e50a595980734ac3ae04 Mon Sep 17 00:00:00 2001 From: zanarelli Date: Sat, 1 Aug 2026 01:03:08 -0300 Subject: [PATCH] Preserve I/O errors in RestClient body extraction readWithMessageConverters wrapped IOException and UncheckedIOException in a RestClientException whose message blamed content-type extraction, hiding timeouts and other I/O failures. Re-throw those as ResourceAccessException (still a RestClientException subclass). Closes gh-37078 Signed-off-by: zanarelli --- .../web/client/DefaultRestClient.java | 4 + .../web/client/DefaultRestClientTests.java | 82 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java index 0b937410d14b..0e1e6f57a361 100644 --- a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java +++ b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java @@ -270,6 +270,10 @@ else if (messageConverter.canRead(bodyClass, contentType)) { else { cause = exc; } + if (cause instanceof IOException ioException) { + throw new ResourceAccessException("I/O error while extracting response for type [" + + ResolvableType.forType(bodyType) + "]", ioException); + } throw new RestClientException("Error while extracting response for type [" + ResolvableType.forType(bodyType) + "] and content type [" + contentType + "]", cause); } diff --git a/spring-web/src/test/java/org/springframework/web/client/DefaultRestClientTests.java b/spring-web/src/test/java/org/springframework/web/client/DefaultRestClientTests.java index 12646929c670..e357a11f5db8 100644 --- a/spring-web/src/test/java/org/springframework/web/client/DefaultRestClientTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/DefaultRestClientTests.java @@ -19,6 +19,8 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.SocketTimeoutException; import java.net.URI; import java.util.function.Consumer; import java.util.stream.Stream; @@ -31,6 +33,7 @@ import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -38,9 +41,14 @@ import org.springframework.http.client.ClientHttpRequest; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotReadableException; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; @@ -121,6 +129,80 @@ void requiredBodyWithParameterizedTypeReferenceAndNullBody() throws IOException ); } + @Test // gh-37078 + void bodyWhenConverterThrowsIOExceptionThenResourceAccessException() throws IOException { + mockSentRequest(HttpMethod.GET, URL); + mockResponseStatus(HttpStatus.OK); + mockResponseBody(BODY, MediaType.TEXT_PLAIN); + + HttpMessageConverter converter = mock(); + given(converter.canRead(String.class, MediaType.TEXT_PLAIN)).willReturn(true); + given(converter.read(eq(String.class), any(HttpInputMessage.class))) + .willThrow(new SocketTimeoutException("Read timed out")); + + RestClient client = RestClient.builder() + .requestFactory(this.requestFactory) + .configureMessageConverters(converters -> converters + .disableDefaults() + .addCustomConverter(converter)) + .build(); + + assertThatExceptionOfType(ResourceAccessException.class) + .isThrownBy(() -> client.get().uri(URL).retrieve().body(String.class)) + .withMessageContaining("I/O error while extracting response for type") + .withMessageNotContaining("content type") + .withCauseInstanceOf(SocketTimeoutException.class); + } + + @Test // gh-37078 + void bodyWhenConverterThrowsUncheckedIOExceptionThenResourceAccessException() throws IOException { + mockSentRequest(HttpMethod.GET, URL); + mockResponseStatus(HttpStatus.OK); + mockResponseBody(BODY, MediaType.TEXT_PLAIN); + + HttpMessageConverter converter = mock(); + given(converter.canRead(String.class, MediaType.TEXT_PLAIN)).willReturn(true); + given(converter.read(eq(String.class), any(HttpInputMessage.class))) + .willThrow(new UncheckedIOException(new SocketTimeoutException("Read timed out"))); + + RestClient client = RestClient.builder() + .requestFactory(this.requestFactory) + .configureMessageConverters(converters -> converters + .disableDefaults() + .addCustomConverter(converter)) + .build(); + + assertThatExceptionOfType(ResourceAccessException.class) + .isThrownBy(() -> client.get().uri(URL).retrieve().body(String.class)) + .withMessageContaining("I/O error while extracting response for type") + .withCauseInstanceOf(SocketTimeoutException.class); + } + + @Test // gh-37078 + void bodyWhenConverterThrowsHttpMessageNotReadableExceptionThenRestClientException() throws IOException { + mockSentRequest(HttpMethod.GET, URL); + mockResponseStatus(HttpStatus.OK); + mockResponseBody(BODY, MediaType.TEXT_PLAIN); + + HttpMessageConverter converter = mock(); + given(converter.canRead(String.class, MediaType.TEXT_PLAIN)).willReturn(true); + given(converter.read(eq(String.class), any(HttpInputMessage.class))) + .willThrow(new HttpMessageNotReadableException("Could not read", this.response)); + + RestClient client = RestClient.builder() + .requestFactory(this.requestFactory) + .configureMessageConverters(converters -> converters + .disableDefaults() + .addCustomConverter(converter)) + .build(); + + assertThatExceptionOfType(RestClientException.class) + .isThrownBy(() -> client.get().uri(URL).retrieve().body(String.class)) + .isNotInstanceOf(ResourceAccessException.class) + .withMessageContaining("content type") + .withCauseInstanceOf(HttpMessageNotReadableException.class); + } + @ParameterizedTest(name = "{0}") @MethodSource("streamResponseBodies") void streamingBody(String typeName, Consumer clientConsumer) throws IOException {