Skip to content

Commit f6c2bd6

Browse files
authored
copying over changes from storage PR (#50760)
1 parent 1593b75 commit f6c2bd6

5 files changed

Lines changed: 246 additions & 7 deletions

File tree

‎sdk/core/azure-core-http-netty/CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
## 1.17.0-beta.1 (Unreleased)
44

55
### Features Added
6+
- Added opt-in observation of the `Expect: 100-continue` handshake. When a caller places an `AtomicBoolean` in the
7+
request `Context` under the key `azure-http-client-expect-continue-received`, the client sets it to `true` if the
8+
service answers with an interim `100 Continue` response. This does not change how or when the request body is sent.
69

710
### Breaking Changes
811

‎sdk/core/azure-core-http-netty/src/main/java/com/azure/core/http/netty/NettyAsyncHttpClient.java‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
import java.nio.file.StandardOpenOption;
5353
import java.time.Duration;
5454
import java.util.Objects;
55+
import java.util.concurrent.atomic.AtomicBoolean;
5556
import java.util.function.BiFunction;
5657

5758
/**
@@ -95,6 +96,12 @@ class NettyAsyncHttpClient implements HttpClient {
9596
private static final ClientLogger LOGGER = new ClientLogger(NettyAsyncHttpClient.class);
9697
private static final byte[] EMPTY_BYTES = new byte[0];
9798

99+
// Context key under which a caller may place an AtomicBoolean that this client sets to true when an interim
100+
// "100 Continue" response is observed for the request. This is an opt-in contract usable by any caller that wants
101+
// to learn whether the service engaged in the "Expect: 100-continue" handshake: the caller places a holder under
102+
// this key before sending and reads it afterward. Callers must use this same key string.
103+
private static final String EXPECT_CONTINUE_RECEIVED_KEY = "azure-http-client-expect-continue-received";
104+
98105
final boolean disableBufferCopy;
99106
final boolean addProxyHandler;
100107
final reactor.netty.http.client.HttpClient nettyClient;
@@ -136,24 +143,30 @@ public Mono<HttpResponse> send(HttpRequest request, Context context) {
136143
.map(timeoutDuration -> ((Duration) timeoutDuration).toMillis())
137144
.orElse(null);
138145
ProgressReporter progressReporter = Contexts.with(context).getHttpRequestProgressReporter();
146+
AtomicBoolean expectContinueReceived = context.getData(EXPECT_CONTINUE_RECEIVED_KEY)
147+
.filter(AtomicBoolean.class::isInstance)
148+
.map(AtomicBoolean.class::cast)
149+
.orElse(null);
139150

140151
return attemptAsync(request, eagerlyReadResponse, ignoreResponseBody, headersEagerlyConverted, responseTimeout,
141-
progressReporter, false);
152+
progressReporter, expectContinueReceived, false);
142153
}
143154

144155
private Mono<HttpResponse> attemptAsync(HttpRequest request, boolean eagerlyReadResponse,
145156
boolean ignoreResponseBody, boolean headersEagerlyConverted, Long responseTimeout,
146-
ProgressReporter progressReporter, boolean proxyRetry) {
157+
ProgressReporter progressReporter, AtomicBoolean expectContinueReceived, boolean proxyRetry) {
147158
Flux<Tuple2<HttpResponse, HttpHeaders>> nettyRequest
148159
= nettyClient.request(toReactorNettyHttpMethod(request.getHttpMethod()))
149160
.uri(request.getUrl().toString())
150161
.send(bodySendDelegate(request))
151162
.responseConnection(responseDelegate(request, disableBufferCopy, eagerlyReadResponse,
152163
ignoreResponseBody, headersEagerlyConverted));
153164

154-
if (responseTimeout != null || progressReporter != null) {
165+
// Attach the per-request context only when something needs it: a response-timeout override, a progress
166+
// reporter, or a holder to record whether an interim "100 Continue" response was observed.
167+
if (responseTimeout != null || progressReporter != null || expectContinueReceived != null) {
155168
nettyRequest = nettyRequest.contextWrite(ctx -> ctx.put(AzureNettyHttpClientContext.KEY,
156-
new AzureNettyHttpClientContext(responseTimeout, progressReporter)));
169+
new AzureNettyHttpClientContext(responseTimeout, progressReporter, expectContinueReceived)));
157170
}
158171

159172
return nettyRequest.single().flatMap(responseAndHeaders -> {
@@ -166,15 +179,15 @@ private Mono<HttpResponse> attemptAsync(HttpRequest request, boolean eagerlyRead
166179
} else {
167180
// Retry the request.
168181
return attemptAsync(request, eagerlyReadResponse, ignoreResponseBody, headersEagerlyConverted,
169-
responseTimeout, progressReporter, true);
182+
responseTimeout, progressReporter, expectContinueReceived, true);
170183
}
171184
} else {
172185
return Mono.just(response);
173186
}
174187
})
175188
.onErrorResume(throwable -> shouldRetryProxyError(proxyRetry, throwable)
176189
? attemptAsync(request, eagerlyReadResponse, ignoreResponseBody, headersEagerlyConverted,
177-
responseTimeout, progressReporter, true)
190+
responseTimeout, progressReporter, expectContinueReceived, true)
178191
: Mono.error(throwable));
179192
}
180193

‎sdk/core/azure-core-http-netty/src/main/java/com/azure/core/http/netty/implementation/AzureNettyHttpClientContext.java‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import com.azure.core.util.ProgressReporter;
77

8+
import java.util.concurrent.atomic.AtomicBoolean;
9+
810
/**
911
* A holder for all context that may be passed through the Netty HttpClient.
1012
*/
@@ -13,15 +15,24 @@ public final class AzureNettyHttpClientContext {
1315
private final Long responseTimeoutOverride;
1416
private final ProgressReporter progressReporter;
1517

18+
// Optional caller-provided holder, set to true when an interim "100 Continue" response is observed for the
19+
// request. The holder is placed in the request Context by a caller (for example a pipeline policy) that wants to
20+
// observe the "Expect: 100-continue" handshake. May be null.
21+
private final AtomicBoolean expectContinueReceivedHolder;
22+
1623
/**
1724
* Creates an instance of AzureNettyHttpClientContext.
1825
*
1926
* @param responseTimeoutOverride The response timeout override.
2027
* @param progressReporter The progress reporter.
28+
* @param expectContinueReceivedHolder Optional caller-provided holder set to {@code true} when a
29+
* {@code 100 Continue} response is observed. May be null.
2130
*/
22-
public AzureNettyHttpClientContext(Long responseTimeoutOverride, ProgressReporter progressReporter) {
31+
public AzureNettyHttpClientContext(Long responseTimeoutOverride, ProgressReporter progressReporter,
32+
AtomicBoolean expectContinueReceivedHolder) {
2333
this.responseTimeoutOverride = responseTimeoutOverride;
2434
this.progressReporter = progressReporter;
35+
this.expectContinueReceivedHolder = expectContinueReceivedHolder;
2536
}
2637

2738
/**
@@ -41,4 +52,14 @@ public Long getResponseTimeoutOverride() {
4152
public ProgressReporter getProgressReporter() {
4253
return progressReporter;
4354
}
55+
56+
/**
57+
* Gets the optional holder that is set to {@code true} when an interim {@code 100 Continue} response is observed
58+
* for this request. May be null.
59+
*
60+
* @return The holder, or null if the caller did not supply one.
61+
*/
62+
public AtomicBoolean getExpectContinueReceivedHolder() {
63+
return expectContinueReceivedHolder;
64+
}
4465
}

‎sdk/core/azure-core-http-netty/src/main/java/com/azure/core/http/netty/implementation/AzureSdkHandler.java‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@
1212
import io.netty.channel.ChannelOutboundBuffer;
1313
import io.netty.channel.ChannelPromise;
1414
import io.netty.channel.FileRegion;
15+
import io.netty.handler.codec.http.HttpResponse;
16+
import io.netty.handler.codec.http.HttpResponseStatus;
1517
import io.netty.handler.codec.http.LastHttpContent;
1618

1719
import java.util.concurrent.ScheduledFuture;
1820
import java.util.concurrent.TimeUnit;
1921
import java.util.concurrent.TimeoutException;
22+
import java.util.concurrent.atomic.AtomicBoolean;
2023

2124
/**
2225
* Class containing all ChannelHandler concepts that the Azure SDKs use.
@@ -34,6 +37,7 @@ public final class AzureSdkHandler extends ChannelDuplexHandler {
3437

3538
private final long writeTimeoutMillis;
3639
private final ProgressReporter progressReporter;
40+
private final AtomicBoolean expectContinueReceivedHolder;
3741
private long lastWriteMillis;
3842
private long lastWriteProgress;
3943
private boolean writeTrackingStarted;
@@ -65,6 +69,7 @@ public AzureSdkHandler(AzureNettyHttpClientContext context, long writeTimeoutMil
6569
long readTimeoutMillis) {
6670
this.writeTimeoutMillis = writeTimeoutMillis;
6771
this.progressReporter = (context != null) ? context.getProgressReporter() : null;
72+
this.expectContinueReceivedHolder = (context != null) ? context.getExpectContinueReceivedHolder() : null;
6873
this.responseTimeoutMillis = (context != null && context.getResponseTimeoutOverride() != null)
6974
? context.getResponseTimeoutOverride()
7075
: responseTimeoutMillis;
@@ -215,6 +220,15 @@ private void disposeResponseTimeoutWatcher() {
215220

216221
@Override
217222
public void channelRead(ChannelHandlerContext ctx, Object msg) {
223+
// Observe the interim "100 Continue" response before it is consumed by Reactor Netty. This does not change
224+
// how or when the request body is sent; it only records that the service engaged in the handshake so that
225+
// calling code can see it. Scoped to the exact 100 status so final responses are never matched.
226+
if (expectContinueReceivedHolder != null
227+
&& msg instanceof HttpResponse
228+
&& ((HttpResponse) msg).status().code() == HttpResponseStatus.CONTINUE.code()) {
229+
expectContinueReceivedHolder.set(true);
230+
}
231+
218232
if (responseTrackingStarted) {
219233
endResponseTracking();
220234
startReadTracking();
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package com.azure.core.http.netty;
5+
6+
import com.azure.core.http.HttpClient;
7+
import com.azure.core.http.HttpHeaderName;
8+
import com.azure.core.http.HttpMethod;
9+
import com.azure.core.http.HttpRequest;
10+
import com.azure.core.http.HttpResponse;
11+
import com.azure.core.util.Context;
12+
import org.junit.jupiter.params.ParameterizedTest;
13+
import org.junit.jupiter.params.provider.ValueSource;
14+
15+
import java.io.ByteArrayOutputStream;
16+
import java.io.IOException;
17+
import java.io.InputStream;
18+
import java.io.OutputStream;
19+
import java.net.ServerSocket;
20+
import java.net.Socket;
21+
import java.net.URI;
22+
import java.net.URL;
23+
import java.nio.charset.StandardCharsets;
24+
import java.util.Locale;
25+
import java.util.concurrent.CountDownLatch;
26+
import java.util.concurrent.TimeUnit;
27+
import java.util.concurrent.atomic.AtomicBoolean;
28+
29+
import static org.junit.jupiter.api.Assertions.assertEquals;
30+
import static org.junit.jupiter.api.Assertions.assertFalse;
31+
import static org.junit.jupiter.api.Assertions.assertTrue;
32+
33+
/**
34+
* Verifies that the Netty client surfaces the observation of an interim {@code 100 Continue} response through an
35+
* {@link AtomicBoolean} holder the caller places in the request {@link Context}. The holder is keyed by an opt-in
36+
* string contract any caller may use; the Netty client sets it to {@code true} when the service sends the interim
37+
* response.
38+
*/
39+
public class NettyExpectContinueObservationTests {
40+
private static final byte[] BODY = new byte[1024];
41+
42+
@ParameterizedTest
43+
@ValueSource(booleans = { true, false })
44+
public void interimContinueIsSurfacedThroughContextHolder(boolean sync) throws Exception {
45+
try (ContinueTestServer server = new ContinueTestServer(true)) {
46+
server.start();
47+
AtomicBoolean holder = sendWithHolder(server, sync);
48+
assertTrue(server.awaitRequest(), "request never reached the server");
49+
assertTrue(holder.get(), "100 Continue was not surfaced through the context holder");
50+
}
51+
}
52+
53+
@ParameterizedTest
54+
@ValueSource(booleans = { true, false })
55+
public void holderStaysFalseWhenServerSendsNoContinue(boolean sync) throws Exception {
56+
try (ContinueTestServer server = new ContinueTestServer(false)) {
57+
server.start();
58+
AtomicBoolean holder = sendWithHolder(server, sync);
59+
assertTrue(server.awaitRequest(), "request never reached the server");
60+
assertFalse(holder.get(), "holder should stay false when no 100 Continue is sent");
61+
}
62+
}
63+
64+
// Must match the private key NettyAsyncHttpClient reads for the opt-in 100-continue observation contract.
65+
private static final String EXPECT_CONTINUE_RECEIVED_KEY = "azure-http-client-expect-continue-received";
66+
67+
private static AtomicBoolean sendWithHolder(ContinueTestServer server, boolean sync) throws IOException {
68+
HttpClient client = new NettyAsyncHttpClientBuilder().build();
69+
AtomicBoolean holder = new AtomicBoolean(false);
70+
Context context = new Context(EXPECT_CONTINUE_RECEIVED_KEY, holder);
71+
72+
HttpRequest request = new HttpRequest(HttpMethod.PUT, server.url()).setBody(BODY);
73+
request.getHeaders().set(HttpHeaderName.EXPECT, "100-continue");
74+
75+
HttpResponse response = sync ? client.sendSync(request, context) : client.send(request, context).block();
76+
assertEquals(201, response.getStatusCode());
77+
response.close();
78+
return holder;
79+
}
80+
81+
/**
82+
* Minimal HTTP/1.1 server that optionally answers the 100-continue handshake, then reads the body and returns 201.
83+
*/
84+
private static final class ContinueTestServer implements AutoCloseable {
85+
private final ServerSocket serverSocket;
86+
private final boolean sendContinue;
87+
private final CountDownLatch requestHandled = new CountDownLatch(1);
88+
private volatile Thread thread;
89+
90+
ContinueTestServer(boolean sendContinue) throws IOException {
91+
this.serverSocket = new ServerSocket(0);
92+
this.sendContinue = sendContinue;
93+
}
94+
95+
URL url() throws IOException {
96+
return URI.create("http://localhost:" + serverSocket.getLocalPort() + "/resource").toURL();
97+
}
98+
99+
void start() {
100+
thread = new Thread(this::serve, "continue-test-server");
101+
thread.setDaemon(true);
102+
thread.start();
103+
}
104+
105+
boolean awaitRequest() throws InterruptedException {
106+
return requestHandled.await(30, TimeUnit.SECONDS);
107+
}
108+
109+
private void serve() {
110+
try (Socket socket = serverSocket.accept()) {
111+
socket.setTcpNoDelay(true);
112+
InputStream in = socket.getInputStream();
113+
OutputStream out = socket.getOutputStream();
114+
115+
Parsed parsed = readHeaders(in);
116+
117+
if (sendContinue && parsed.expectHeader != null) {
118+
out.write("HTTP/1.1 100 Continue\r\n\r\n".getBytes(StandardCharsets.UTF_8));
119+
out.flush();
120+
}
121+
122+
readBody(in, parsed.contentLength);
123+
124+
out.write("HTTP/1.1 201 Created\r\nContent-Length: 0\r\n\r\n".getBytes(StandardCharsets.UTF_8));
125+
out.flush();
126+
} catch (IOException ex) {
127+
// The test assertions report the failure.
128+
} finally {
129+
requestHandled.countDown();
130+
}
131+
}
132+
133+
private static Parsed readHeaders(InputStream in) throws IOException {
134+
ByteArrayOutputStream headerBytes = new ByteArrayOutputStream();
135+
int consecutiveNewlines = 0;
136+
int b;
137+
while (consecutiveNewlines < 2 && (b = in.read()) != -1) {
138+
headerBytes.write(b);
139+
if (b == '\n') {
140+
consecutiveNewlines++;
141+
} else if (b != '\r') {
142+
consecutiveNewlines = 0;
143+
}
144+
}
145+
146+
Parsed parsed = new Parsed();
147+
for (String line : new String(headerBytes.toByteArray(), StandardCharsets.UTF_8).split("\r\n")) {
148+
int colon = line.indexOf(':');
149+
if (colon < 0) {
150+
continue;
151+
}
152+
String name = line.substring(0, colon).trim().toLowerCase(Locale.ROOT);
153+
String value = line.substring(colon + 1).trim();
154+
if ("expect".equals(name)) {
155+
parsed.expectHeader = value.toLowerCase(Locale.ROOT);
156+
} else if ("content-length".equals(name)) {
157+
parsed.contentLength = Integer.parseInt(value);
158+
}
159+
}
160+
return parsed;
161+
}
162+
163+
private static void readBody(InputStream in, int remaining) throws IOException {
164+
byte[] buffer = new byte[Math.max(remaining, 1)];
165+
int read = 0;
166+
while (read < remaining) {
167+
int count = in.read(buffer, 0, Math.min(buffer.length, remaining - read));
168+
if (count < 0) {
169+
break;
170+
}
171+
read += count;
172+
}
173+
}
174+
175+
@Override
176+
public void close() throws IOException {
177+
serverSocket.close();
178+
if (thread != null) {
179+
thread.interrupt();
180+
}
181+
}
182+
183+
private static final class Parsed {
184+
private String expectHeader;
185+
private int contentLength;
186+
}
187+
}
188+
}

0 commit comments

Comments
 (0)