Skip to content

Commit 03b58f9

Browse files
authored
Improved Expect: 100-continue response handling in the URL connection client. (#3050)
* Improved Expect: 100-continue response handling in the URL connection client. HTTPURLConnection currently throws a ProtocolException if the service returns anything other than a 100 or 417 response code to an expect-100-continue request. S3 sometimes triggers this behavior. This change catches this exception and treats it according to its response code, assuming that it can verify that no information was lost (e.g. because of a response payload being included in the response). If it still fails, it instructs the customer that the apache HTTP client could be a true solution to the problem. * Addressed comments.
1 parent 4bf4d3a commit 03b58f9

File tree

5 files changed

+425
-6
lines changed

5 files changed

+425
-6
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"category": "URL Connection HTTP Client",
3+
"contributor": "",
4+
"type": "bugfix",
5+
"description": "Fix \"java.net.ProtocolException: Server rejected operation\" when the HTTP response has no response content. If the response includes content, update the exception message to say that the Apache HTTP client may fix the problem."
6+
}

http-clients/url-connection-client/pom.xml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,39 @@
8989
<version>${awsjavasdk.version}</version>
9090
<scope>test</scope>
9191
</dependency>
92+
<dependency>
93+
<groupId>software.amazon.awssdk</groupId>
94+
<artifactId>test-utils</artifactId>
95+
<version>${awsjavasdk.version}</version>
96+
<scope>test</scope>
97+
</dependency>
98+
<dependency>
99+
<groupId>org.apache.logging.log4j</groupId>
100+
<artifactId>log4j-api</artifactId>
101+
<scope>test</scope>
102+
</dependency>
103+
<dependency>
104+
<groupId>org.apache.logging.log4j</groupId>
105+
<artifactId>log4j-core</artifactId>
106+
<scope>test</scope>
107+
</dependency>
108+
<dependency>
109+
<groupId>org.apache.logging.log4j</groupId>
110+
<artifactId>log4j-slf4j-impl</artifactId>
111+
<scope>test</scope>
112+
</dependency>
113+
<dependency>
114+
<groupId>javax.servlet</groupId>
115+
<artifactId>javax.servlet-api</artifactId>
116+
<version>3.1.0</version>
117+
<scope>test</scope>
118+
</dependency>
119+
<dependency>
120+
<groupId>org.eclipse.jetty</groupId>
121+
<artifactId>jetty-server</artifactId>
122+
<version>9.2.24.v20180105</version>
123+
<scope>test</scope>
124+
</dependency>
92125
</dependencies>
93126

94127
<build>

http-clients/url-connection-client/src/main/java/software/amazon/awssdk/http/urlconnection/UrlConnectionHttpClient.java

Lines changed: 114 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323

2424
import java.io.IOException;
2525
import java.io.InputStream;
26+
import java.io.OutputStream;
27+
import java.io.UncheckedIOException;
2628
import java.net.HttpURLConnection;
2729
import java.net.URI;
2830
import java.security.KeyManagementException;
@@ -31,6 +33,9 @@
3133
import java.time.Duration;
3234
import java.util.List;
3335
import java.util.Map;
36+
import java.util.Objects;
37+
import java.util.Optional;
38+
import java.util.function.Supplier;
3439
import java.util.stream.Collectors;
3540
import javax.net.ssl.HostnameVerifier;
3641
import javax.net.ssl.HttpsURLConnection;
@@ -42,6 +47,7 @@
4247
import javax.net.ssl.X509TrustManager;
4348
import software.amazon.awssdk.annotations.SdkPublicApi;
4449
import software.amazon.awssdk.http.AbortableInputStream;
50+
import software.amazon.awssdk.http.ContentStreamProvider;
4551
import software.amazon.awssdk.http.ExecutableHttpRequest;
4652
import software.amazon.awssdk.http.HttpExecuteRequest;
4753
import software.amazon.awssdk.http.HttpExecuteResponse;
@@ -195,10 +201,20 @@ private SSLContext getSslContext(AttributeMap options) {
195201
}
196202

197203
private static class RequestCallable implements ExecutableHttpRequest {
198-
199204
private final HttpURLConnection connection;
200205
private final HttpExecuteRequest request;
201206

207+
/**
208+
* Whether we encountered the 'bug' in the way the HttpURLConnection handles 'Expect: 100-continue' cases. See
209+
* {@link #getAndHandle100Bug} for more information.
210+
*/
211+
private boolean expect100BugEncountered = false;
212+
213+
/**
214+
* Result cache for {@link #responseHasNoContent()}.
215+
*/
216+
private Boolean responseHasNoContent;
217+
202218
private RequestCallable(HttpURLConnection connection, HttpExecuteRequest request) {
203219
this.connection = connection;
204220
this.request = request;
@@ -208,14 +224,19 @@ private RequestCallable(HttpURLConnection connection, HttpExecuteRequest request
208224
public HttpExecuteResponse call() throws IOException {
209225
connection.connect();
210226

211-
request.contentStreamProvider().ifPresent(provider ->
212-
invokeSafely(() -> IoUtils.copy(provider.newStream(), connection.getOutputStream())));
227+
Optional<ContentStreamProvider> requestContent = request.contentStreamProvider();
228+
229+
if (requestContent.isPresent()) {
230+
Optional<OutputStream> outputStream = tryGetOutputStream();
231+
if (outputStream.isPresent()) {
232+
IoUtils.copy(requestContent.get().newStream(), outputStream.get());
233+
}
234+
}
213235

214236
int responseCode = getResponseCodeSafely(connection);
215237
boolean isErrorResponse = HttpStatusFamily.of(responseCode).isOneOf(CLIENT_ERROR, SERVER_ERROR);
216-
InputStream content = !isErrorResponse ? connection.getInputStream() : connection.getErrorStream();
217-
AbortableInputStream responseBody = content != null ?
218-
AbortableInputStream.create(content) : null;
238+
Optional<InputStream> responseContent = isErrorResponse ? tryGetErrorStream() : tryGetInputStream();
239+
AbortableInputStream responseBody = responseContent.map(AbortableInputStream::create).orElse(null);
219240

220241
return HttpExecuteResponse.builder()
221242
.response(SdkHttpResponse.builder()
@@ -228,6 +249,93 @@ public HttpExecuteResponse call() throws IOException {
228249
.build();
229250
}
230251

252+
private Optional<OutputStream> tryGetOutputStream() {
253+
return getAndHandle100Bug(() -> invokeSafely(connection::getOutputStream), false);
254+
}
255+
256+
private Optional<InputStream> tryGetInputStream() {
257+
return getAndHandle100Bug(() -> invokeSafely(connection::getInputStream), true);
258+
}
259+
260+
private Optional<InputStream> tryGetErrorStream() {
261+
InputStream result = invokeSafely(connection::getErrorStream);
262+
if (result == null && expect100BugEncountered) {
263+
log.debug(() -> "The response payload has been dropped because of a limitation of the JDK's URL Connection "
264+
+ "HTTP client, resulting in a less descriptive SDK exception error message. Using "
265+
+ "the Apache HTTP client removes this limitation.");
266+
}
267+
return Optional.ofNullable(result);
268+
}
269+
270+
/**
271+
* This handles a bug in {@link HttpURLConnection#getOutputStream()} and {@link HttpURLConnection#getInputStream()}
272+
* where these methods will throw a ProtocolException if we sent an "Expect: 100-continue" header, and the
273+
* service responds with something other than a 100.
274+
*
275+
* HttpUrlConnection still gives us access to the response code and headers when this bug is encountered, so our
276+
* handling of the bug is:
277+
* <ol>
278+
* <li>If the service returned a response status or content length that indicates there was no response payload,
279+
* we ignore that we couldn't read the response payload, and just return the response with what we have.</li>
280+
* <li>If the service returned a payload and we can't read it because of the bug, we throw an exception for
281+
* non-failure cases (2xx, 3xx) or log and return the response without the payload for failure cases (4xx or 5xx)
282+
* .</li>
283+
* </ol>
284+
*/
285+
private <T> Optional<T> getAndHandle100Bug(Supplier<T> supplier, boolean failOn100Bug) {
286+
try {
287+
return Optional.ofNullable(supplier.get());
288+
} catch (RuntimeException e) {
289+
if (!exceptionCausedBy100HandlingBug(e)) {
290+
throw e;
291+
}
292+
293+
if (responseHasNoContent()) {
294+
return Optional.empty();
295+
}
296+
297+
expect100BugEncountered = true;
298+
299+
if (!failOn100Bug) {
300+
return Optional.empty();
301+
}
302+
303+
int responseCode = invokeSafely(connection::getResponseCode);
304+
String message = "Unable to read response payload, because service returned response code "
305+
+ responseCode + " to an Expect: 100-continue request. Using another HTTP client "
306+
+ "implementation (e.g. Apache) removes this limitation.";
307+
throw new UncheckedIOException(new IOException(message, e));
308+
}
309+
}
310+
311+
private boolean exceptionCausedBy100HandlingBug(RuntimeException e) {
312+
return requestWasExpect100Continue() &&
313+
e.getMessage() != null &&
314+
e.getMessage().startsWith("java.net.ProtocolException: Server rejected operation");
315+
}
316+
317+
private Boolean requestWasExpect100Continue() {
318+
return request.httpRequest()
319+
.firstMatchingHeader("Expect")
320+
.map(expect -> expect.equalsIgnoreCase("100-continue"))
321+
.orElse(false);
322+
}
323+
324+
private boolean responseHasNoContent() {
325+
// We cannot account for chunked encoded responses, because we only have access to headers and response code here,
326+
// so we assume chunked encoded responses DO have content.
327+
if (responseHasNoContent == null) {
328+
responseHasNoContent = responseNeverHasPayload(invokeSafely(connection::getResponseCode)) ||
329+
Objects.equals(connection.getHeaderField("Content-Length"), "0") ||
330+
Objects.equals(connection.getRequestMethod(), "HEAD");
331+
}
332+
return responseHasNoContent;
333+
}
334+
335+
private boolean responseNeverHasPayload(int responseCode) {
336+
return responseCode == 204 || responseCode == 304 || (responseCode >= 100 && responseCode < 200);
337+
}
338+
231339
/**
232340
* {@link sun.net.www.protocol.http.HttpURLConnection#getInputStream0()} has been observed to intermittently throw
233341
* {@link NullPointerException}s for reasons that still require further investigation, but are assumed to be due to a

0 commit comments

Comments
 (0)