This is similar to https://bz.apache.org/bugzilla/show_bug.cgi?id=68037, but for BadRequestException. When using Async servlets I am neither able to set a error status nor am I able to get a correct 400 in the response. However it works for read timeouts correctly where I get a 408. Reproducible test : import org.apache.catalina.Context; import org.apache.catalina.LifecycleException; import org.apache.catalina.Wrapper; import org.apache.catalina.connector.ClientAbortException; import org.apache.catalina.connector.Connector; import org.apache.catalina.core.StandardHost; import org.apache.catalina.startup.Tomcat; import org.apache.coyote.BadRequestException; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import javax.servlet.ReadListener; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.Socket; import java.net.SocketTimeoutException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; public class ReproducibleTest { static Tomcat tomcat; @BeforeAll static void setup() throws LifecycleException { tomcat = new Tomcat(); ExecutorService executorService = Executors.newFixedThreadPool(5); Context ctx = tomcat.addContext("", new File(".").getAbsolutePath()); Wrapper asyncWrapper = Tomcat.addServlet(ctx, "TestAsync", new TestAsyncServlet()); Wrapper syncWrapper = Tomcat.addServlet(ctx, "TestSync", new SyncServlet()); asyncWrapper.setAsyncSupported(true); StandardHost host = (StandardHost) tomcat.getHost(); host.setErrorReportValveClass(null); Connector connector = new Connector(); connector.setProperty("address", "http://localhost"); connector.setPort(8000); connector.setProperty("connectionTimeout", String.valueOf(100)); connector.getProtocolHandler().setExecutor(executorService); tomcat.getService().addConnector(connector); ctx.addServletMappingDecoded("/async/*", "TestAsync"); ctx.addServletMappingDecoded("/sync/*", "TestSync"); tomcat.start(); } @AfterAll static void destroy() throws LifecycleException { tomcat.stop(); tomcat.destroy(); } @Test @DisplayName("requests that time out while reading the payload get a 408") void timeoutGetsA408() throws Exception { try (Socket s = new Socket("localhost", 8000)) { String request = "GET /sync HTTP/1.1\r\nHost: localhost\r\ncontent-length: 100\r\n\r\n"; sendBadRequest(s, request, 408); } } @Test @DisplayName("requests that time out while reading the payload get a 408") void timeoutGetsA408Async() throws Exception { try (Socket s = new Socket("localhost", 8000)) { String request = "GET /async HTTP/1.1\r\nHost: localhost\r\ncontent-length: 100\r\n\r\n"; sendBadRequest(s, request, 408); } } @Test void badChunksAsync() throws IOException { try (Socket s = new Socket("localhost", 8000)) { String request = "GET /async HTTP/1.1\r\nHost: localhost\r\nTransfer-encoding: chunked\r\n\r\n1\r\na\r\r"; sendBadRequest(s, request, 400); } } @Test void badChunks() throws Exception { try (Socket s = new Socket("localhost", 8000)) { String request = "GET /sync HTTP/1.1\r\nHost: localhost\r\nTransfer-encoding: chunked\r\n\r\n1\r\na\r\r"; sendBadRequest(s, request, 400); } } private static void sendBadRequest(Socket socket, String request, int expectedStatusCode) throws IOException { OutputStream os = socket.getOutputStream(); os.write(request.getBytes(UTF_8)); InputStream is = socket.getInputStream(); BufferedReader reader = new BufferedReader(new InputStreamReader(is, UTF_8)); String opening = reader.readLine(); assertNotNull(opening, "Didn't get back a response"); StringBuilder sb = new StringBuilder(opening); try { assertTrue(opening.startsWith("HTTP/1.1 " + expectedStatusCode), "expected status code " + expectedStatusCode + " but got " + opening); boolean connectionClose = false; while (reader.ready()) { String line = reader.readLine(); if (line == null) { break; } sb.append("\n").append(line); if ("connection: close".equalsIgnoreCase(line)) { connectionClose = true; } assertFalse(line.contains("Exception Report")); assertFalse(line.contains("Status Report")); } assertTrue(connectionClose, "No 'Connection: close' header seen"); } catch (Throwable t) { fail("Response:\n" + sb, t); } } static final class SyncServlet extends HttpServlet { @Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { try { while (req.getInputStream().read() != -1) ; resp.setStatus(200); resp.flushBuffer(); } catch (ClientAbortException e) { if(!resp.isCommitted()) { resp.sendError(408); } } } } static final class TestAsyncServlet extends HttpServlet { @Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.startAsync(); req.getInputStream().setReadListener(new ReadListener() { @Override public void onDataAvailable() throws IOException { while (req.getInputStream().isReady()) { req.getInputStream().read(); } } @Override public void onAllDataRead() throws IOException { resp.setStatus(200); req.getAsyncContext().complete(); } @Override public void onError(Throwable t) { if (t instanceof BadRequestException || t instanceof SocketTimeoutException) { try { resp.setStatus(t instanceof BadRequestException ? 400 : 408); resp.flushBuffer(); } catch (IOException e) { e.printStackTrace(); } } req.getAsyncContext().complete(); } }); } } }
Also similar to https://bz.apache.org/bugzilla/show_bug.cgi?id=68228
https://bz.apache.org/bugzilla/show_bug.cgi?id=68037#c7
I have already read the comment you are referencing. The solution by Mark works for SocketTimeout, but doesn't work for BadRequest. Further even if Tomcat doesn't allow setting a status on BadRequest, it should at the least send back a 404 instead of just closing the connection.
Any update on this? I can give fixing this a shot if no one else is planning to.
I haven't looked at this yet but I suspect it may end up as WONTFIX. A bad request indicates a syntactically invalid request. At that point, it can be argued that given that the request is invalid, Tomcat can't tell what is going on so the only safe thing to do is to close the connection.
I agree on the closing connection part. Its just weird that sync and async behave differently, one sends out a 400 and other doesn't. Also the way you suggested in https://bz.apache.org/bugzilla/show_bug.cgi?id=68037#c7, doesn't work for BRE but works for SocketTimeout.
How do you get a valid HTTP request (not a 400) but then find yourself in a "Bad Request" state once async gets started?
I've been able to look at this some more. Thanks so much for the test case. It really speeds up the process. The processing paths for sync and async are distinct. Currently the error handling in async is handled much further up the stack than sync. That is a factor in why async is more blunt in its error handling. I'm looking to see if I can make the error handling more nuanced for async.
I've made a modification that should allow async error handling to write to the response. The provided test case now passes.