Skip to content

Commit 9a29b39

Browse files
jrhee17ikhoon
andauthored
ClientRequestContext#cancel cancels the associated request immediately (#5800)
Motivation: In order to handle #4591, I propose that we first define an API which allows users to cancel a request. Currently, `ClientRequestContext#cancel` is invoked once `CancellationScheduler#start` is invoked. I propose to change the behavior of `ClientRequestContext#cancel` such that the associated request is aborted immediately. Once this API is available, it would be trivial to implement `ResponseTimeoutMode` by adjusting where to call `CancellationScheduler#start`. Additionally, users may easily implement their own timeout mechanism if they would like. Modifications: - `CancellationScheduler` related changes - `DefaultCancellationScheduler` is refactored to be lock based instead of event loop based. The reasoning for this change was for the scenario where the request execution didn't reach the event loop yet. i.e. If a user calls `ctx.cancel` in the decorator, a connection shouldn't be opened. - e.g. https://github.com/line/armeria/blob/59aa40a59e1f1122716e70f9f1d6f1402a6aae0e/core/src/test/java/com/linecorp/armeria/client/ContextCancellationTest.java#L90-L116 - `CancellationScheduler#updateTask` is introduced. This API updates the cancellation task if the scheduler isn't invoked yet. If the scheduler is invoked already, the cancellation task will be executed eventually. This API allows `ctx.cancel` to attempt cancellation depending on which stage the request is at. For instance, at the decorators only `req.abort` needs to be called but at the request write stage, the cancellation task may need to send a reset signal. - Misc. an infinite timeout is internally represented as `Long.MAX_VALUE` instead of `0` - `AbstractHttpRequestHandler` related changes - `CancellationTask` in `AbstractHttpRequestHandler`, `HttpResponseWrapper`, `AbstractHttpResponseHandler` is modified to be scheduled inside an event loop. The reasoning is that `ctx.cancel`, and hence `CancellationTask#run` can be invoked from any thread. - There is a higher chance of `AbstractHttpRequestHandler` calling `fail` or `failAndReset` multiple times. There is no point in doing so, so added a boolean flag `failed` for safety. - `HttpResponseWrapper` related changes - The original intention of `cancelTimeoutAndLog` was to not log if the response is unexpected. Modified so that if the response is cancelled or the context is cancelled, no logging is done - There is probably no reason to not call `close` when a timeout occurs. Unified the fragmented logic of closing the `HttpResponseWrapper`. Result: - Users can call `ClientRequestContext#cancel` to cancel the ongoing request easily. - #5793 can be prepared for <!-- Visit this URL to learn more about how to write a pull request description: https://armeria.dev/community/developer-guide#how-to-write-pull-request-description --> --------- Co-authored-by: Ikhun Um <[email protected]>
1 parent faa886f commit 9a29b39

16 files changed

+1063
-576
lines changed

core/src/main/java/com/linecorp/armeria/client/AbstractHttpRequestHandler.java

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
import com.linecorp.armeria.internal.client.ClientRequestContextExtension;
4646
import com.linecorp.armeria.internal.client.DecodedHttpResponse;
4747
import com.linecorp.armeria.internal.client.HttpSession;
48+
import com.linecorp.armeria.internal.common.CancellationScheduler;
49+
import com.linecorp.armeria.internal.common.CancellationScheduler.CancellationTask;
4850
import com.linecorp.armeria.internal.common.RequestContextUtil;
4951
import com.linecorp.armeria.unsafe.PooledObjects;
5052

@@ -89,6 +91,7 @@ enum State {
8991
private ScheduledFuture<?> timeoutFuture;
9092
private State state = State.NEEDS_TO_WRITE_FIRST_HEADER;
9193
private boolean loggedRequestFirstBytesTransferred;
94+
private boolean failed;
9295

9396
AbstractHttpRequestHandler(Channel ch, ClientHttpObjectEncoder encoder, HttpResponseDecoder responseDecoder,
9497
DecodedHttpResponse originalRes,
@@ -192,9 +195,30 @@ final boolean tryInitialize() {
192195
() -> failAndReset(WriteTimeoutException.get()),
193196
timeoutMillis, TimeUnit.MILLISECONDS);
194197
}
198+
final CancellationScheduler scheduler = cancellationScheduler();
199+
if (scheduler != null) {
200+
scheduler.updateTask(newCancellationTask());
201+
}
202+
if (ctx.isCancelled()) {
203+
// The previous cancellation task wraps the cause with an UnprocessedRequestException
204+
// so we return early
205+
return false;
206+
}
195207
return true;
196208
}
197209

210+
private CancellationTask newCancellationTask() {
211+
return cause -> {
212+
if (ch.eventLoop().inEventLoop()) {
213+
try (SafeCloseable ignored = RequestContextUtil.pop()) {
214+
failAndReset(cause);
215+
}
216+
} else {
217+
ch.eventLoop().execute(() -> failAndReset(cause));
218+
}
219+
};
220+
}
221+
198222
RequestHeaders mergedRequestHeaders(RequestHeaders headers) {
199223
final HttpHeaders internalHeaders;
200224
final ClientRequestContextExtension ctxExtension = ctx.as(ClientRequestContextExtension.class);
@@ -354,6 +378,10 @@ final void failRequest(Throwable cause) {
354378
}
355379

356380
private void fail(Throwable cause) {
381+
if (failed) {
382+
return;
383+
}
384+
failed = true;
357385
state = State.DONE;
358386
cancel();
359387
logBuilder.endRequest(cause);
@@ -368,9 +396,20 @@ private void fail(Throwable cause) {
368396
logBuilder.endResponse(cause);
369397
originalRes.close(cause);
370398
}
399+
400+
final CancellationScheduler scheduler = cancellationScheduler();
401+
if (scheduler != null) {
402+
// best-effort attempt to cancel the scheduled timeout task so that RequestContext#cause
403+
// isn't set unnecessarily
404+
scheduler.cancelScheduled();
405+
}
371406
}
372407

373408
final void failAndReset(Throwable cause) {
409+
if (failed) {
410+
return;
411+
}
412+
374413
if (cause instanceof WriteTimeoutException) {
375414
final HttpSession session = HttpSession.get(ch);
376415
// Mark the session as unhealthy so that subsequent requests do not use it.
@@ -394,7 +433,7 @@ final void failAndReset(Throwable cause) {
394433
error = Http2Error.INTERNAL_ERROR;
395434
}
396435

397-
if (error.code() != Http2Error.CANCEL.code()) {
436+
if (error.code() != Http2Error.CANCEL.code() && cause != ctx.cancellationCause()) {
398437
Exceptions.logIfUnexpected(logger, ch,
399438
HttpSession.get(ch).protocol(),
400439
"a request publisher raised an exception", cause);
@@ -415,4 +454,13 @@ final boolean cancelTimeout() {
415454
this.timeoutFuture = null;
416455
return timeoutFuture.cancel(false);
417456
}
457+
458+
@Nullable
459+
private CancellationScheduler cancellationScheduler() {
460+
final ClientRequestContextExtension ctxExt = ctx.as(ClientRequestContextExtension.class);
461+
if (ctxExt != null) {
462+
return ctxExt.responseCancellationScheduler();
463+
}
464+
return null;
465+
}
418466
}

core/src/main/java/com/linecorp/armeria/client/HttpClientDelegate.java

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@
3333
import com.linecorp.armeria.common.annotation.Nullable;
3434
import com.linecorp.armeria.common.logging.ClientConnectionTimings;
3535
import com.linecorp.armeria.common.logging.ClientConnectionTimingsBuilder;
36-
import com.linecorp.armeria.common.logging.RequestLogBuilder;
3736
import com.linecorp.armeria.common.util.SafeCloseable;
3837
import com.linecorp.armeria.internal.client.ClientPendingThrowableUtil;
38+
import com.linecorp.armeria.internal.client.ClientRequestContextExtension;
3939
import com.linecorp.armeria.internal.client.DecodedHttpResponse;
4040
import com.linecorp.armeria.internal.client.HttpSession;
4141
import com.linecorp.armeria.internal.client.PooledChannel;
@@ -63,13 +63,13 @@ final class HttpClientDelegate implements HttpClient {
6363
public HttpResponse execute(ClientRequestContext ctx, HttpRequest req) throws Exception {
6464
final Throwable throwable = ClientPendingThrowableUtil.pendingThrowable(ctx);
6565
if (throwable != null) {
66-
return earlyFailedResponse(throwable, ctx, req);
66+
return earlyFailedResponse(throwable, ctx);
6767
}
6868
if (req != ctx.request()) {
6969
return earlyFailedResponse(
7070
new IllegalStateException("ctx.request() does not match the actual request; " +
7171
"did you forget to call ctx.updateRequest() in your decorator?"),
72-
ctx, req);
72+
ctx);
7373
}
7474

7575
final Endpoint endpoint = ctx.endpoint();
@@ -84,21 +84,27 @@ public HttpResponse execute(ClientRequestContext ctx, HttpRequest req) throws Ex
8484
// and response created here will be exposed only when `EndpointGroup.select()` returned `null`.
8585
//
8686
// See `DefaultClientRequestContext.init()` for more information.
87-
return earlyFailedResponse(EmptyEndpointGroupException.get(ctx.endpointGroup()), ctx, req);
87+
return earlyFailedResponse(EmptyEndpointGroupException.get(ctx.endpointGroup()), ctx);
8888
}
8989

9090
final SessionProtocol protocol = ctx.sessionProtocol();
9191
final ProxyConfig proxyConfig;
9292
try {
9393
proxyConfig = getProxyConfig(protocol, endpoint);
9494
} catch (Throwable t) {
95-
return earlyFailedResponse(t, ctx, req);
95+
return earlyFailedResponse(t, ctx);
96+
}
97+
98+
final Throwable cancellationCause = ctx.cancellationCause();
99+
if (cancellationCause != null) {
100+
return earlyFailedResponse(cancellationCause, ctx);
96101
}
97102

98103
final Endpoint endpointWithPort = endpoint.withDefaultPort(ctx.sessionProtocol());
99104
final EventLoop eventLoop = ctx.eventLoop().withoutContext();
100105
// TODO(ikhoon) Use ctx.exchangeType() to create an optimized HttpResponse for non-streaming response.
101106
final DecodedHttpResponse res = new DecodedHttpResponse(eventLoop);
107+
updateCancellationTask(ctx, req, res);
102108

103109
final ClientConnectionTimingsBuilder timingsBuilder = ClientConnectionTimings.builder();
104110

@@ -115,14 +121,31 @@ public HttpResponse execute(ClientRequestContext ctx, HttpRequest req) throws Ex
115121
acquireConnectionAndExecute(ctx, resolved, req, res, timingsBuilder, proxyConfig);
116122
} else {
117123
ctx.logBuilder().session(null, ctx.sessionProtocol(), timingsBuilder.build());
118-
earlyFailedResponse(cause, ctx, req, res);
124+
ctx.cancel(cause);
119125
}
120126
});
121127
}
122128

123129
return res;
124130
}
125131

132+
private static void updateCancellationTask(ClientRequestContext ctx, HttpRequest req,
133+
DecodedHttpResponse res) {
134+
final ClientRequestContextExtension ctxExt = ctx.as(ClientRequestContextExtension.class);
135+
if (ctxExt == null) {
136+
return;
137+
}
138+
ctxExt.responseCancellationScheduler().updateTask(cause -> {
139+
try (SafeCloseable ignored = RequestContextUtil.pop()) {
140+
final UnprocessedRequestException ure = UnprocessedRequestException.of(cause);
141+
req.abort(ure);
142+
ctx.logBuilder().endRequest(ure);
143+
res.close(ure);
144+
ctx.logBuilder().endResponse(ure);
145+
}
146+
});
147+
}
148+
126149
private void resolveAddress(Endpoint endpoint, ClientRequestContext ctx,
127150
BiConsumer<@Nullable Endpoint, @Nullable Throwable> onComplete) {
128151

@@ -169,7 +192,7 @@ private void acquireConnectionAndExecute0(ClientRequestContext ctx, Endpoint end
169192
try {
170193
pool = factory.pool(ctx.eventLoop().withoutContext());
171194
} catch (Throwable t) {
172-
earlyFailedResponse(t, ctx, req, res);
195+
ctx.cancel(t);
173196
return;
174197
}
175198
final SessionProtocol protocol = ctx.sessionProtocol();
@@ -185,7 +208,7 @@ private void acquireConnectionAndExecute0(ClientRequestContext ctx, Endpoint end
185208
if (cause == null) {
186209
doExecute(newPooledChannel, ctx, req, res);
187210
} else {
188-
earlyFailedResponse(cause, ctx, req, res);
211+
ctx.cancel(cause);
189212
}
190213
return null;
191214
});
@@ -224,30 +247,12 @@ private static void logSession(ClientRequestContext ctx, @Nullable PooledChannel
224247
}
225248
}
226249

227-
private static HttpResponse earlyFailedResponse(Throwable t, ClientRequestContext ctx, HttpRequest req) {
250+
private static HttpResponse earlyFailedResponse(Throwable t, ClientRequestContext ctx) {
228251
final UnprocessedRequestException cause = UnprocessedRequestException.of(t);
229-
handleEarlyRequestException(ctx, req, cause);
252+
ctx.cancel(cause);
230253
return HttpResponse.ofFailure(cause);
231254
}
232255

233-
private static void earlyFailedResponse(Throwable t, ClientRequestContext ctx, HttpRequest req,
234-
DecodedHttpResponse res) {
235-
final UnprocessedRequestException cause = UnprocessedRequestException.of(t);
236-
handleEarlyRequestException(ctx, req, cause);
237-
res.close(cause);
238-
}
239-
240-
private static void handleEarlyRequestException(ClientRequestContext ctx,
241-
HttpRequest req, Throwable cause) {
242-
try (SafeCloseable ignored = RequestContextUtil.pop()) {
243-
req.abort(cause);
244-
final RequestLogBuilder logBuilder = ctx.logBuilder();
245-
logBuilder.endRequest(cause);
246-
logBuilder.endResponse(cause);
247-
ctx.cancel(cause);
248-
}
249-
}
250-
251256
private static void doExecute(PooledChannel pooledChannel, ClientRequestContext ctx,
252257
HttpRequest req, DecodedHttpResponse res) {
253258
final Channel channel = pooledChannel.get();

core/src/main/java/com/linecorp/armeria/client/HttpResponseWrapper.java

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@
3737
import com.linecorp.armeria.common.stream.StreamWriter;
3838
import com.linecorp.armeria.common.stream.SubscriptionOption;
3939
import com.linecorp.armeria.common.util.Exceptions;
40+
import com.linecorp.armeria.common.util.SafeCloseable;
4041
import com.linecorp.armeria.internal.client.ClientRequestContextExtension;
4142
import com.linecorp.armeria.internal.client.DecodedHttpResponse;
4243
import com.linecorp.armeria.internal.common.CancellationScheduler;
4344
import com.linecorp.armeria.internal.common.CancellationScheduler.CancellationTask;
45+
import com.linecorp.armeria.internal.common.RequestContextUtil;
4446
import com.linecorp.armeria.unsafe.PooledObjects;
4547

4648
import io.netty.channel.EventLoop;
@@ -213,7 +215,7 @@ void close(@Nullable Throwable cause, boolean cancel) {
213215
}
214216
done = true;
215217
closed = true;
216-
cancelTimeoutOrLog(cause, cancel);
218+
cancelTimeoutAndLog(cause, cancel);
217219
final HttpRequest request = ctx.request();
218220
assert request != null;
219221
if (cause != null) {
@@ -250,32 +252,24 @@ private void cancelAction(@Nullable Throwable cause) {
250252
}
251253
}
252254

253-
private void cancelTimeoutOrLog(@Nullable Throwable cause, boolean cancel) {
254-
CancellationScheduler responseCancellationScheduler = null;
255+
private void cancelTimeoutAndLog(@Nullable Throwable cause, boolean cancel) {
255256
final ClientRequestContextExtension ctxExtension = ctx.as(ClientRequestContextExtension.class);
256257
if (ctxExtension != null) {
257-
responseCancellationScheduler = ctxExtension.responseCancellationScheduler();
258+
// best-effort attempt to cancel the scheduled timeout task so that RequestContext#cause
259+
// isn't set unnecessarily
260+
ctxExtension.responseCancellationScheduler().cancelScheduled();
258261
}
259262

260-
if (responseCancellationScheduler == null || !responseCancellationScheduler.isFinished()) {
261-
if (responseCancellationScheduler != null) {
262-
responseCancellationScheduler.clearTimeout(false);
263-
}
264-
// There's no timeout or the response has not been timed out.
265-
if (cancel) {
266-
cancelAction(cause);
267-
} else {
268-
closeAction(cause);
269-
}
263+
if (cancel) {
264+
cancelAction(cause);
270265
return;
271266
}
272267
if (delegate.isOpen()) {
273268
closeAction(cause);
274269
}
275270

276-
// Response has been timed out already.
277-
// Log only when it's not a ResponseTimeoutException.
278-
if (cause instanceof ResponseTimeoutException) {
271+
// the context has been cancelled either by timeout or by user invocation
272+
if (cause == ctx.cancellationCause()) {
279273
return;
280274
}
281275

@@ -299,7 +293,8 @@ void initTimeout() {
299293
if (ctxExtension != null) {
300294
final CancellationScheduler responseCancellationScheduler =
301295
ctxExtension.responseCancellationScheduler();
302-
responseCancellationScheduler.start(newCancellationTask());
296+
responseCancellationScheduler.updateTask(newCancellationTask());
297+
responseCancellationScheduler.start();
303298
}
304299
}
305300

@@ -312,11 +307,13 @@ public boolean canSchedule() {
312307

313308
@Override
314309
public void run(Throwable cause) {
315-
delegate.close(cause);
316-
final HttpRequest request = ctx.request();
317-
assert request != null;
318-
request.abort(cause);
319-
ctx.logBuilder().endResponse(cause);
310+
if (ctx.eventLoop().inEventLoop()) {
311+
try (SafeCloseable ignored = RequestContextUtil.pop()) {
312+
close(cause);
313+
}
314+
} else {
315+
ctx.eventLoop().withoutContext().execute(() -> close(cause));
316+
}
320317
}
321318
};
322319
}

core/src/main/java/com/linecorp/armeria/common/RequestContext.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,9 @@ default void setRequestAutoAbortDelay(Duration delay) {
467467

468468
/**
469469
* Returns the cause of cancellation, {@code null} if the request has not been cancelled.
470+
* Note that there is no guarantee that the cancellation cause is equivalent to the cause of failure
471+
* for {@link HttpRequest} or {@link HttpResponse}. Refer to {@link RequestLog#requestCause()}
472+
* or {@link RequestLog#responseCause()} for the exact reason why a request or response failed.
470473
*/
471474
@Nullable
472475
Throwable cancellationCause();

0 commit comments

Comments
 (0)