Skip to content

Commit 68cf247

Browse files
committed
Candidate fix for an issue with HTTP 1xx responses.
We were returning the origin connection to the pool after getting a 1xx HTTP response, which was incorrect – those are responses that indicate a further response was coming. In some cases, the origin's eventual (real) response could actually go to the wrong client in these cases. This also fixes an issue where the client -> proxy connection explicitly handled HTTP 100 (Continue), but not any other 1xx response. The others aren't common, but we should handle them anyway. Some Claude code in here, fwiw, but I've reviewed enough that I'm to blame for mistakes :)
1 parent 078bda3 commit 68cf247

3 files changed

Lines changed: 48 additions & 16 deletions

File tree

zuul-core/src/main/java/com/netflix/netty/common/HttpClientLifecycleChannelHandler.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import io.netty.channel.ChannelPromise;
2424
import io.netty.handler.codec.http.HttpRequest;
2525
import io.netty.handler.codec.http.HttpResponse;
26+
import io.netty.handler.codec.http.HttpStatusClass;
2627
import io.netty.handler.codec.http.LastHttpContent;
2728
import io.netty.util.AttributeKey;
2829

@@ -48,7 +49,12 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception
4849
super.channelRead(ctx, msg);
4950
} finally {
5051
if (msg instanceof LastHttpContent) {
51-
fireCompleteEventIfNotAlready(ctx, CompleteReason.SESSION_COMPLETE);
52+
HttpResponse resp = ctx.channel().attr(ATTR_HTTP_RESP).get();
53+
boolean isInformational =
54+
resp != null && resp.status().codeClass() == HttpStatusClass.INFORMATIONAL;
55+
if (!isInformational) {
56+
fireCompleteEventIfNotAlready(ctx, CompleteReason.SESSION_COMPLETE);
57+
}
5258
}
5359
}
5460
}

zuul-core/src/main/java/com/netflix/netty/common/HttpServerLifecycleChannelHandler.java

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,9 @@
2323
import io.netty.channel.ChannelPromise;
2424
import io.netty.handler.codec.http.HttpRequest;
2525
import io.netty.handler.codec.http.HttpResponse;
26-
import io.netty.handler.codec.http.HttpResponseStatus;
26+
import io.netty.handler.codec.http.HttpStatusClass;
2727
import io.netty.handler.codec.http.LastHttpContent;
2828
import io.netty.util.ReferenceCountUtil;
29-
import java.util.Objects;
3029

3130
/**
3231
* @author michaels
@@ -68,19 +67,21 @@ public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise)
6867
} finally {
6968
if (msg instanceof LastHttpContent) {
7069

71-
boolean dontFireCompleteYet = false;
70+
// Handle case of 100 CONTINUE (or other 1xx responses), where server sends an initial 100 status
71+
// response to indicate to client that it can continue sending the initial request body.
72+
// i.e. in this case we don't want to consider the state to be COMPLETE until after the 2nd
73+
// response.
74+
HttpResponse resp;
7275
if (msg instanceof HttpResponse httpResponse) {
73-
// Handle case of 100 CONTINUE, where server sends an initial 100 status response to indicate to
74-
// client
75-
// that it can continue sending the initial request body.
76-
// ie. in this case we don't want to consider the state to be COMPLETE until after the 2nd
77-
// response.
78-
if (Objects.equals(httpResponse.status(), HttpResponseStatus.CONTINUE)) {
79-
dontFireCompleteYet = true;
80-
}
76+
resp = httpResponse;
77+
} else {
78+
// 1xx responses forwarded from the origin are often forwarded as two separate pipeline events
79+
// (the actual 1xx response and an empty LastHttpContent). In that case, httpResponse is null.
80+
resp = ctx.channel().attr(ATTR_HTTP_RESP).get();
8181
}
82-
83-
if (!dontFireCompleteYet) {
82+
boolean isInformational =
83+
resp != null && resp.status().codeClass() == HttpStatusClass.INFORMATIONAL;
84+
if (!isInformational) {
8485
if (promise.isDone()) {
8586
fireCompleteEventIfNotAlready(ctx, CompleteReason.SESSION_COMPLETE);
8687
} else {

zuul-core/src/main/java/com/netflix/zuul/filters/endpoint/ProxyEndpoint.java

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
import io.netty.handler.codec.http.DefaultLastHttpContent;
8383
import io.netty.handler.codec.http.HttpContent;
8484
import io.netty.handler.codec.http.HttpResponse;
85+
import io.netty.handler.codec.http.HttpStatusClass;
8586
import io.netty.handler.codec.http.LastHttpContent;
8687
import io.netty.util.ReferenceCountUtil;
8788
import io.netty.util.concurrent.Future;
@@ -137,6 +138,7 @@ public class ProxyEndpoint extends SyncZuulFilterAdapter<HttpRequestMessage, Htt
137138
protected MethodBinding<?> methodBinding;
138139
protected HttpResponseMessage zuulResponse;
139140
protected boolean startedSendingResponseToClient;
141+
private boolean pendingInterimResponse;
140142
protected Duration timeLeftForAttempt;
141143

142144
/* Individual retry related state */
@@ -432,7 +434,13 @@ private void filterResponseChunk(HttpContent chunk) {
432434
}
433435

434436
if (chunk instanceof LastHttpContent) {
435-
unlinkFromOrigin();
437+
if (pendingInterimResponse) {
438+
// This empty LastHttpContent is the tail of a 1xx interim response; the origin connection
439+
// must stay open for the real final response that follows.
440+
pendingInterimResponse = false;
441+
} else {
442+
unlinkFromOrigin();
443+
}
436444
}
437445

438446
if (responseFilters != null) {
@@ -838,13 +846,30 @@ public void responseFromOrigin(HttpResponse originResponse) {
838846
}
839847

840848
private void processResponseFromOrigin(HttpResponse originResponse) {
841-
if (originResponse.status().code() >= 500) {
849+
if (originResponse.status().codeClass() == HttpStatusClass.INFORMATIONAL) {
850+
handleInterimResponse(originResponse);
851+
} else if (originResponse.status().code() >= 500) {
842852
handleOriginNonSuccessResponse(originResponse, chosenServer.get());
843853
} else {
844854
handleOriginSuccessResponse(originResponse, chosenServer.get());
845855
}
846856
}
847857

858+
private void handleInterimResponse(HttpResponse originResponse) {
859+
int respStatus = originResponse.status().code();
860+
HttpResponseMessage interimZuulResponse = new HttpResponseMessageImpl(context, zuulRequest, respStatus);
861+
Headers respHeaders = interimZuulResponse.getHeaders();
862+
for (Map.Entry<String, String> entry : originResponse.headers()) {
863+
respHeaders.add(entry.getKey(), entry.getValue());
864+
}
865+
// Track that we are mid-interim-response so filterResponseChunk does not call unlinkFromOrigin()
866+
// on the empty LastHttpContent that Netty emits after every 1xx.
867+
// Do NOT set startedSendingResponseToClient and do NOT record final metrics or response status.
868+
pendingInterimResponse = true;
869+
zuulResponse = interimZuulResponse;
870+
invokeNext(interimZuulResponse);
871+
}
872+
848873
protected void handleOriginSuccessResponse(HttpResponse originResponse, DiscoveryResult chosenServer) {
849874
origin.recordSuccessResponse();
850875
if (originConn != null) {

0 commit comments

Comments
 (0)