Skip to content

Commit adac996

Browse files
committed
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.
1 parent 5e5a854 commit adac996

File tree

5 files changed

+417
-6
lines changed

5 files changed

+417
-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: 106 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,15 @@ 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+
202213
private RequestCallable(HttpURLConnection connection, HttpExecuteRequest request) {
203214
this.connection = connection;
204215
this.request = request;
@@ -208,14 +219,19 @@ private RequestCallable(HttpURLConnection connection, HttpExecuteRequest request
208219
public HttpExecuteResponse call() throws IOException {
209220
connection.connect();
210221

211-
request.contentStreamProvider().ifPresent(provider ->
212-
invokeSafely(() -> IoUtils.copy(provider.newStream(), connection.getOutputStream())));
222+
Optional<ContentStreamProvider> requestContent = request.contentStreamProvider();
223+
224+
if (requestContent.isPresent()) {
225+
Optional<OutputStream> outputStream = tryGetOutputStream();
226+
if (outputStream.isPresent()) {
227+
IoUtils.copy(requestContent.get().newStream(), outputStream.get());
228+
}
229+
}
213230

214231
int responseCode = getResponseCodeSafely(connection);
215232
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;
233+
Optional<InputStream> responseContent = isErrorResponse ? tryGetErrorStream() : tryGetInputStream();
234+
AbortableInputStream responseBody = responseContent.map(AbortableInputStream::create).orElse(null);
219235

220236
return HttpExecuteResponse.builder()
221237
.response(SdkHttpResponse.builder()
@@ -228,6 +244,90 @@ public HttpExecuteResponse call() throws IOException {
228244
.build();
229245
}
230246

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

0 commit comments

Comments
 (0)