Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow repeatable writes in StreamingHttpOutputMessage #31449

Closed
cachescrubber opened this issue Oct 17, 2023 · 10 comments
Closed

Allow repeatable writes in StreamingHttpOutputMessage #31449

cachescrubber opened this issue Oct 17, 2023 · 10 comments
Assignees
Labels
in: web Issues in web modules (web, webmvc, webflux, websocket) type: enhancement A general enhancement
Milestone

Comments

@cachescrubber
Copy link

cachescrubber commented Oct 17, 2023

BufferingClientHttpRequestFactory creates requests which are not repeatable

A RestTemplate obtained form the following snippet will lose the default request configuration applied to the HttpComponentsClientHttpRequestFactory, in this case the setRedirectsEnabled(true) is not honored.

  • Affected Version: spring-boot 3.2.0-M3
  • Works with stable: spring-boot 3.1.4
  • Affected http client library is Apache HttpComponents 5 (HttpComponentsClientHttpRequestFactory).

I prepared a reproducible example here. Just run com.example.httpclient5demo.Httpclient5DemoApplicationTests

		RestTemplate restTemplate = restTemplateBuilder
				.requestFactory(() -> {
					RequestConfig.Builder requestConfig = RequestConfig.custom()
							.setRedirectsEnabled(true);
					HttpClientBuilder httpClientBuilder = HttpClientBuilder.create()
							.setDefaultRequestConfig(requestConfig.build());
					HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClientBuilder.build());
					//return requestFactory;
					return new BufferingClientHttpRequestFactory(requestFactory);
				})
				.rootUri("https://httpbin.org")
				.build();
		String response = restTemplate.getForObject("/redirect-to?url=https://httpbin.org/base64/SFRUUEJJTiBpcyBhd2Vzb21l", String.class);
		assertThat(response).isEqualTo("HTTPBIN is awesome");

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Oct 17, 2023
@wilkinsona
Copy link
Member

Thanks for the report and sample project. This doesn't appear to be a Spring Boot problem. The following tests illustrate the behavior without using Spring Boot:

package com.example.httpclient5demo;

import static org.assertj.core.api.Assertions.assertThat;

import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.junit.jupiter.api.Test;
import org.springframework.http.client.BufferingClientHttpRequestFactory;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;

class Httpclient5DemoApplicationTests {
	
	@Test
	void redirectsCanBeEnabled() {
		RestTemplate restTemplate = new RestTemplate();
		RequestConfig.Builder requestConfig = RequestConfig.custom()
				.setRedirectsEnabled(true);
		HttpClientBuilder httpClientBuilder = HttpClientBuilder.create()
				.setDefaultRequestConfig(requestConfig.build());
		HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClientBuilder.build());
		restTemplate.setRequestFactory(requestFactory);
		String response = restTemplate.getForObject("https://httpbin.org/redirect-to?url=https://httpbin.org/base64/SFRUUEJJTiBpcyBhd2Vzb21l", String.class);
		assertThat(response).isEqualTo("HTTPBIN is awesome");
	}

	@Test
	void redirectsCanBeEnabledWhenBuffering() {
		RestTemplate restTemplate = new RestTemplate();
		RequestConfig.Builder requestConfig = RequestConfig.custom()
				.setRedirectsEnabled(true);
		HttpClientBuilder httpClientBuilder = HttpClientBuilder.create()
				.setDefaultRequestConfig(requestConfig.build());
		HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClientBuilder.build());
		restTemplate.setRequestFactory(new BufferingClientHttpRequestFactory(requestFactory));
		String response = restTemplate.getForObject("https://httpbin.org/redirect-to?url=https://httpbin.org/base64/SFRUUEJJTiBpcyBhd2Vzb21l", String.class);
		assertThat(response).isEqualTo("HTTPBIN is awesome");
	}

}

redirectsCanBeEnabled passes but redirectsCanBeEnabledWhenBuffering fails. We'll transfer this to the Framework team so that they can take a look.

@snicoll snicoll transferred this issue from spring-projects/spring-boot Oct 17, 2023
@cachescrubber
Copy link
Author

Thank you @wilkinsona for analyzing the issue. I updated the example with your test cases.

@cachescrubber
Copy link
Author

I spotted another issue. Basic Auth (401) and Proxy Auth (407) Challenges are not working when buffering is enabled.

	@Test
	void basicAuthCanBeEnabledWhenBuffering() {
		RestTemplate restTemplate = new RestTemplate();
		BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();

		credentialsProvider.setCredentials(new AuthScope("httpbin.org", 443),
				new UsernamePasswordCredentials("demo", "secret".toCharArray()));

		HttpClientBuilder httpClientBuilder = HttpClientBuilder.create()
				.setDefaultCredentialsProvider(credentialsProvider);

		HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClientBuilder.build());
		restTemplate.setRequestFactory(new BufferingClientHttpRequestFactory(requestFactory));
		ResponseEntity<String> forEntity = restTemplate.getForEntity("https://httpbin.org//basic-auth/demo/secret", String.class);
		assertThat(forEntity.getStatusCode().is2xxSuccessful()).isTrue();
	}

The example has been updated with a test case.

@cachescrubber
Copy link
Author

The issue is not a configuration issue, as I assumed initialy. I'll change the issue title accordingly.

It is caused by changes introduced in #30557 . BufferingClientHttpRequestWrapper is creating an empty body, which is passed to HttpComponents as org.springframework.http.client.HttpComponentsClientHttpRequest.BodyEntity which in turn implements org.apache.hc.core5.http.HttpEntity#isRepeatable to return false unconditionally.

HttpEntity#isRepeatable returning false disables all kind of functionality in the library where requests are repeated - redirects (3xx), authentication challenges (401,407) but also internal retries.

A quick fix would be to not pass in empty HttpComponentsClientHttpRequest.BodyEntity for requests where no body is expected (GET, HEAD, ...).

But I think the changes in #30557 are problematic in this regard. As it is implemented right now, even requests using the BufferingClientHttpRequestFactory are converted into a streaming body, even though they are in-memory anyway.

@cachescrubber cachescrubber changed the title RestTemplateBuilder with a BufferingClientHttpRequestFactory looses the wrapped RequestFactory BufferingClientHttpRequestFactory creates requests which are not repeatable Oct 23, 2023
@poutsma poutsma self-assigned this Oct 23, 2023
@poutsma poutsma added the in: web Issues in web modules (web, webmvc, webflux, websocket) label Oct 23, 2023
@poutsma poutsma added this to the 6.1.0-RC2 milestone Oct 23, 2023
@poutsma poutsma changed the title BufferingClientHttpRequestFactory creates requests which are not repeatable Allow repeatable writes in StreamingHttpOutputMessage Oct 24, 2023
@poutsma poutsma added type: enhancement A general enhancement and removed status: waiting-for-triage An issue we've not yet triaged or decided on labels Oct 24, 2023
@cachescrubber
Copy link
Author

Thanks @poutsma , my HttpComponents based test are good again. From reading the code I think at least OkHttp3ClientHttpRequest.BodyRequestBody#isOneShot need to be updated as well. Not sure for the other implementations.

@poutsma
Copy link
Contributor

poutsma commented Oct 24, 2023

Thanks for spotting that, it should now be fixed.

poutsma added a commit that referenced this issue Nov 2, 2023
This commit ensures that the StreamingHttpOutputMessage.Body.repeatable
flag is set in message converters for bodies that can be written
repeatedly.

Closes gh-31516
See gh-31449
@poutsma
Copy link
Contributor

poutsma commented Nov 2, 2023

@cachescrubber I made some further related changes in 6dd93d4, which should enable repeatable writes for many types of request bodies, meaning that you typically don't need to wrap your request factory in a BufferingClientHttpRequestFactory any more. Please try a 6.1.0 Spring Framework snapshot and see if it works for you.

@cachescrubber
Copy link
Author

Thanks for the heads-up. I use BufferingClientHttpRequestFactory to implement request/response logging in a ClientHttpRequestInterceptor. IIRC we needed the buffering in order to use response#getBody multiple times.

@karlovskiy
Copy link

Hi, I found this issue today during my investigation of redirects which stopped working after spring update.
I found that in this line we have
streamingOutputMessage.setBody(outputStream -> StreamUtils.copy(body, outputStream));
but if body was set like this with lambda than repeatable will be false.
Is it intended behavior ?
I thought that if we have byte array in InterceptingClientHttpRequest maybe body there also should be repeatable ?

PS: Currently, I resolved my redirect issue with wrapping HttpComponentsClientHttpRequestFactory with BufferingClientHttpRequestFactory.

@poutsma
Copy link
Contributor

poutsma commented Feb 14, 2024

@karlovskiy well spotted, this should be fixed in 6.1.4.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: web Issues in web modules (web, webmvc, webflux, websocket) type: enhancement A general enhancement
Projects
None yet
Development

No branches or pull requests

5 participants