Skip to content

Commit 5dbfe48

Browse files
committed
Improve async request timeout handling
Rather than setting the status to 503 directly from the timeout interceptor which no longer seems to work reliably with Servlet containers like Jetty even performing an additional ERROR dispatch back to the original URL, we know rather set the DeferredResult to an AsyncTimeoutException, which results in a dispatch and standard handling within Spring MVC. This should be a more reliable way of dealing with timeouts. Issue: SPR-14669
1 parent 07d5f8b commit 5dbfe48

File tree

8 files changed

+124
-29
lines changed

8 files changed

+124
-29
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright 2002-2016 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.web.context.request.async;
17+
18+
/**
19+
* Exception to be thrown when an async request times out.
20+
* Alternatively an applications can register a
21+
* {@link DeferredResultProcessingInterceptor} or a
22+
* {@link CallableProcessingInterceptor} to handle the timeout through
23+
* the MVC Java config or the MVC XML namespace or directly through properties
24+
* of the {@code RequestMappingHandlerAdapter}.
25+
*
26+
* <p>By default the exception will be handled as a 503 error.
27+
*
28+
* @author Rossen Stoyanchev
29+
* @since 4.2.8
30+
*/
31+
@SuppressWarnings("serial")
32+
public class AsyncRequestTimeoutException extends Exception {
33+
34+
}

spring-web/src/main/java/org/springframework/web/context/request/async/TimeoutCallableProcessingInterceptor.java

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2015 the original author or authors.
2+
* Copyright 2002-2016 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -24,7 +24,11 @@
2424

2525
/**
2626
* Sends a 503 (SERVICE_UNAVAILABLE) in case of a timeout if the response is not
27-
* already committed. Registered at the end, after all other interceptors and
27+
* already committed. As of 4.2.8 this is done indirectly by setting the result
28+
* to an {@link AsyncRequestTimeoutException} which is then handled by
29+
* Spring MVC's default exception handling as a 503 error.
30+
*
31+
* <p>Registered at the end, after all other interceptors and
2832
* therefore invoked only if no other interceptor handles the timeout.
2933
*
3034
* <p>Note that according to RFC 7231, a 503 without a 'Retry-After' header is
@@ -39,11 +43,7 @@ public class TimeoutCallableProcessingInterceptor extends CallableProcessingInte
3943

4044
@Override
4145
public <T> Object handleTimeout(NativeWebRequest request, Callable<T> task) throws Exception {
42-
HttpServletResponse servletResponse = request.getNativeResponse(HttpServletResponse.class);
43-
if (!servletResponse.isCommitted()) {
44-
servletResponse.sendError(HttpStatus.SERVICE_UNAVAILABLE.value());
45-
}
46-
return CallableProcessingInterceptor.RESPONSE_HANDLED;
46+
return new AsyncRequestTimeoutException();
4747
}
4848

4949
}

spring-web/src/main/java/org/springframework/web/context/request/async/TimeoutDeferredResultProcessingInterceptor.java

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2015 the original author or authors.
2+
* Copyright 2002-2016 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,14 +16,15 @@
1616

1717
package org.springframework.web.context.request.async;
1818

19-
import javax.servlet.http.HttpServletResponse;
20-
21-
import org.springframework.http.HttpStatus;
2219
import org.springframework.web.context.request.NativeWebRequest;
2320

2421
/**
2522
* Sends a 503 (SERVICE_UNAVAILABLE) in case of a timeout if the response is not
26-
* already committed. Registered at the end, after all other interceptors and
23+
* already committed. As of 4.2.8 this is done indirectly by returning
24+
* {@link AsyncRequestTimeoutException} as the result of processing which is
25+
* then handled by Spring MVC's default exception handling as a 503 error.
26+
*
27+
* <p>Registered at the end, after all other interceptors and
2728
* therefore invoked only if no other interceptor handles the timeout.
2829
*
2930
* <p>Note that according to RFC 7231, a 503 without a 'Retry-After' header is
@@ -37,11 +38,8 @@
3738
public class TimeoutDeferredResultProcessingInterceptor extends DeferredResultProcessingInterceptorAdapter {
3839

3940
@Override
40-
public <T> boolean handleTimeout(NativeWebRequest request, DeferredResult<T> deferredResult) throws Exception {
41-
HttpServletResponse servletResponse = request.getNativeResponse(HttpServletResponse.class);
42-
if (!servletResponse.isCommitted()) {
43-
servletResponse.sendError(HttpStatus.SERVICE_UNAVAILABLE.value());
44-
}
41+
public <T> boolean handleTimeout(NativeWebRequest request, DeferredResult<T> result) throws Exception {
42+
result.setErrorResult(new AsyncRequestTimeoutException());
4543
return false;
4644
}
4745

spring-web/src/test/java/org/springframework/web/context/request/async/WebAsyncManagerTimeoutTests.java

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818

1919
import java.util.concurrent.Callable;
2020
import javax.servlet.AsyncEvent;
21-
import javax.servlet.DispatcherType;
2221

2322
import org.junit.Before;
2423
import org.junit.Test;
@@ -29,9 +28,12 @@
2928
import org.springframework.mock.web.test.MockHttpServletResponse;
3029
import org.springframework.web.context.request.NativeWebRequest;
3130

32-
import static org.junit.Assert.*;
33-
import static org.mockito.BDDMockito.*;
34-
import static org.springframework.web.context.request.async.CallableProcessingInterceptor.*;
31+
import static org.junit.Assert.assertEquals;
32+
import static org.junit.Assert.assertTrue;
33+
import static org.mockito.BDDMockito.given;
34+
import static org.mockito.BDDMockito.mock;
35+
import static org.mockito.BDDMockito.verify;
36+
import static org.springframework.web.context.request.async.CallableProcessingInterceptor.RESULT_NONE;
3537

3638
/**
3739
* {@link WebAsyncManager} tests where container-triggered timeout/completion
@@ -79,9 +81,8 @@ public void startCallableProcessingTimeoutAndComplete() throws Exception {
7981
this.asyncWebRequest.onTimeout(ASYNC_EVENT);
8082
this.asyncWebRequest.onComplete(ASYNC_EVENT);
8183

82-
assertFalse(this.asyncManager.hasConcurrentResult());
83-
assertEquals(DispatcherType.REQUEST, this.servletRequest.getDispatcherType());
84-
assertEquals(503, this.servletResponse.getStatus());
84+
assertTrue(this.asyncManager.hasConcurrentResult());
85+
assertEquals(AsyncRequestTimeoutException.class, this.asyncManager.getConcurrentResult().getClass());
8586

8687
verify(interceptor).beforeConcurrentHandling(this.asyncWebRequest, callable);
8788
verify(interceptor).afterCompletion(this.asyncWebRequest, callable);
@@ -163,9 +164,8 @@ public void startDeferredResultProcessingTimeoutAndComplete() throws Exception {
163164
this.asyncWebRequest.onTimeout(ASYNC_EVENT);
164165
this.asyncWebRequest.onComplete(ASYNC_EVENT);
165166

166-
assertFalse(this.asyncManager.hasConcurrentResult());
167-
assertEquals(DispatcherType.REQUEST, this.servletRequest.getDispatcherType());
168-
assertEquals(503, this.servletResponse.getStatus());
167+
assertTrue(this.asyncManager.hasConcurrentResult());
168+
assertEquals(AsyncRequestTimeoutException.class, this.asyncManager.getConcurrentResult().getClass());
169169

170170
verify(interceptor).beforeConcurrentHandling(this.asyncWebRequest, deferredResult);
171171
verify(interceptor).preProcess(this.asyncWebRequest, deferredResult);

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import org.springframework.web.bind.annotation.ControllerAdvice;
4545
import org.springframework.web.bind.annotation.ExceptionHandler;
4646
import org.springframework.web.context.request.WebRequest;
47+
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
4748
import org.springframework.web.multipart.support.MissingServletRequestPartException;
4849
import org.springframework.web.servlet.NoHandlerFoundException;
4950
import org.springframework.web.util.WebUtils;
@@ -114,7 +115,8 @@ public abstract class ResponseEntityExceptionHandler {
114115
MethodArgumentNotValidException.class,
115116
MissingServletRequestPartException.class,
116117
BindException.class,
117-
NoHandlerFoundException.class
118+
NoHandlerFoundException.class,
119+
AsyncRequestTimeoutException.class
118120
})
119121
public final ResponseEntity<Object> handleException(Exception ex, WebRequest request) {
120122
HttpHeaders headers = new HttpHeaders();
@@ -178,6 +180,11 @@ else if (ex instanceof NoHandlerFoundException) {
178180
HttpStatus status = HttpStatus.NOT_FOUND;
179181
return handleNoHandlerFoundException((NoHandlerFoundException) ex, headers, status, request);
180182
}
183+
else if (ex instanceof AsyncRequestTimeoutException) {
184+
HttpStatus status = HttpStatus.SERVICE_UNAVAILABLE;
185+
return handleAsyncRequestTimeoutException(
186+
(AsyncRequestTimeoutException) ex, headers, status, request);
187+
}
181188
else {
182189
logger.warn("Unknown exception type: " + ex.getClass().getName());
183190
HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
@@ -449,4 +456,20 @@ protected ResponseEntity<Object> handleNoHandlerFoundException(
449456
return handleExceptionInternal(ex, null, headers, status, request);
450457
}
451458

459+
/**
460+
* Customize the response for NoHandlerFoundException.
461+
* <p>This method delegates to {@link #handleExceptionInternal}.
462+
* @param ex the exception
463+
* @param headers the headers to be written to the response
464+
* @param status the selected response status
465+
* @param request the current request
466+
* @return a {@code ResponseEntity} instance
467+
* @since 4.2.8
468+
*/
469+
protected ResponseEntity<Object> handleAsyncRequestTimeoutException(
470+
AsyncRequestTimeoutException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
471+
472+
return handleExceptionInternal(ex, null, headers, status, request);
473+
}
474+
452475
}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolver.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import org.springframework.web.bind.annotation.ModelAttribute;
4545
import org.springframework.web.bind.annotation.RequestBody;
4646
import org.springframework.web.bind.annotation.RequestPart;
47+
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
4748
import org.springframework.web.multipart.MultipartFile;
4849
import org.springframework.web.multipart.support.MissingServletRequestPartException;
4950
import org.springframework.web.servlet.ModelAndView;
@@ -159,6 +160,10 @@ else if (ex instanceof BindException) {
159160
else if (ex instanceof NoHandlerFoundException) {
160161
return handleNoHandlerFoundException((NoHandlerFoundException) ex, request, response, handler);
161162
}
163+
else if (ex instanceof AsyncRequestTimeoutException) {
164+
return handleAsyncRequestTimeoutException(
165+
(AsyncRequestTimeoutException) ex, request, response, handler);
166+
}
162167
}
163168
catch (Exception handlerException) {
164169
if (logger.isWarnEnabled()) {
@@ -478,6 +483,25 @@ protected ModelAndView handleNoHandlerFoundException(NoHandlerFoundException ex,
478483
return new ModelAndView();
479484
}
480485

486+
/**
487+
* Handle the case where an async request timed out.
488+
* <p>The default implementation sends an HTTP 503 error.
489+
* @param ex the {@link AsyncRequestTimeoutException }to be handled
490+
* @param request current HTTP request
491+
* @param response current HTTP response
492+
* @param handler the executed handler, or {@code null} if none chosen
493+
* at the time of the exception (for example, if multipart resolution failed)
494+
* @return an empty ModelAndView indicating the exception was handled
495+
* @throws IOException potentially thrown from response.sendError()
496+
* @since 4.2.8
497+
*/
498+
protected ModelAndView handleAsyncRequestTimeoutException(AsyncRequestTimeoutException ex,
499+
HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
500+
501+
response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
502+
return new ModelAndView();
503+
}
504+
481505

482506
/**
483507
* Invoked to send a server error. Sets the status to 500 and also sets the

spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2015 the original author or authors.
2+
* Copyright 2002-2016 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -50,6 +50,7 @@
5050
import org.springframework.web.bind.annotation.ExceptionHandler;
5151
import org.springframework.web.context.request.ServletWebRequest;
5252
import org.springframework.web.context.request.WebRequest;
53+
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
5354
import org.springframework.web.context.support.StaticWebApplicationContext;
5455
import org.springframework.web.multipart.support.MissingServletRequestPartException;
5556
import org.springframework.web.servlet.NoHandlerFoundException;
@@ -205,6 +206,11 @@ public void noHandlerFoundException() {
205206
testException(ex);
206207
}
207208

209+
@Test
210+
public void asyncRequestTimeoutException() {
211+
testException(new AsyncRequestTimeoutException());
212+
}
213+
208214
@Test
209215
public void controllerAdvice() throws Exception {
210216
StaticWebApplicationContext cxt = new StaticWebApplicationContext();

spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolverTests.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
import org.springframework.web.bind.MissingPathVariableException;
4141
import org.springframework.web.bind.MissingServletRequestParameterException;
4242
import org.springframework.web.bind.ServletRequestBindingException;
43+
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
4344
import org.springframework.web.multipart.support.MissingServletRequestPartException;
4445
import org.springframework.web.servlet.ModelAndView;
4546
import org.springframework.web.servlet.NoHandlerFoundException;
@@ -216,6 +217,15 @@ public void handleConversionNotSupportedException() throws Exception {
216217
assertSame(ex, request.getAttribute("javax.servlet.error.exception"));
217218
}
218219

220+
@Test // SPR-14669
221+
public void handleAsyncRequestTimeoutException() throws Exception {
222+
Exception ex = new AsyncRequestTimeoutException();
223+
ModelAndView mav = exceptionResolver.resolveException(request, response, null, ex);
224+
assertNotNull("No ModelAndView returned", mav);
225+
assertTrue("No Empty ModelAndView returned", mav.isEmpty());
226+
assertEquals("Invalid status code", 503, response.getStatus());
227+
}
228+
219229

220230
@SuppressWarnings("unused")
221231
public void handle(String arg) {

0 commit comments

Comments
 (0)