Bug 68559 - BadRequestException doesn't send back a 400 when using Async servlets
Summary: BadRequestException doesn't send back a 400 when using Async servlets
Status: RESOLVED FIXED
Alias: None
Product: Tomcat 9
Classification: Unclassified
Component: Servlet (show other bugs)
Version: 9.0.85
Hardware: PC All
: P2 major (vote)
Target Milestone: -----
Assignee: Tomcat Developers Mailing List
URL:
Keywords:
Depends on:
Blocks:
 
Reported: 2024-01-29 21:33 UTC by adwsingh
Modified: 2024-02-08 13:28 UTC (History)
1 user (show)



Attachments

Note You need to log in before you can comment on or make changes to this bug.
Description adwsingh 2024-01-29 21:33:57 UTC
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();
                }
            });
        }
    }
}
Comment 1 adwsingh 2024-01-29 21:35:39 UTC
Also similar to https://bz.apache.org/bugzilla/show_bug.cgi?id=68228
Comment 3 adwsingh 2024-01-30 18:27:25 UTC
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.
Comment 4 adwsingh 2024-02-05 20:30:19 UTC
Any update on this? I can give fixing this a shot if no one else is planning to.
Comment 5 Mark Thomas 2024-02-05 20:39:29 UTC
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.
Comment 6 adwsingh 2024-02-05 20:43:38 UTC
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.
Comment 7 Christopher Schultz 2024-02-05 22:04:12 UTC
How do you get a valid HTTP request (not a 400) but then find yourself in a "Bad Request" state once async gets started?
Comment 8 Mark Thomas 2024-02-07 11:13:23 UTC
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.
Comment 9 Mark Thomas 2024-02-08 13:28:25 UTC
I've made a modification that should allow async error handling to write to the response. The provided test case now passes.