Skip to content

Switching to virtual threads introduces a hardcoded 30-second timeout on GraphQL HTTP requests with no way to override it #1475

@mcebanupgrade

Description

@mcebanupgrade

Problem

Enabling virtual threads (spring.threads.virtual.enabled=true) causes a silent behavioral regression for GraphQL HTTP requests: resolvers that previously ran without any timeout are now terminated at exactly 30 seconds, and there is no property or configuration to change this.

Environment

  • Spring for GraphQL 1.4.x (confirmed on 1.4.5)
  • Spring Boot 3.x with spring.threads.virtual.enabled=true
  • Tomcat (embedded)

Steps to reproduce

  1. Have a GraphQL resolver that takes > 30 seconds
  2. Confirm it completes successfully without virtual threads
  3. Enable spring.threads.virtual.enabled=true
  4. Execute the same query — it now times out at exactly 30s

Expected behavior

Enabling virtual threads does not change the timeout behavior of GraphQL HTTP requests. If no timeout was previously applied, none should apply after the switch.

Actual behavior

The request is terminated at 30 seconds. This is Tomcat's Connector.asyncTimeout default, and there is no Spring property to override it.

Root cause

GraphQlHttpHandler.prepareResponse() returns ServerResponse.async(mono.toFuture()) without a timeout (GraphQlHttpHandler.java:119):

return ServerResponse.async(mono.toFuture());  // no timeout argument

When Spring MVC writes this response, DefaultAsyncServerResponse.writeAsync() creates a new AsyncWebRequest and installs it on the WebAsyncManager, replacing the one HandlerFunctionAdapter had already configured. Because no timeout was passed to ServerResponse.async(...), the replacement DeferredResult has a null timeout value, so startDeferredResultProcessing skips the asyncWebRequest.setTimeout(...) call entirely. StandardServletAsyncWebRequest.startAsync() then calls asyncContext.startAsync() without setting any timeout, falling through to Tomcat's Connector.asyncTimeout default of 30 seconds.

The bug only surfaces with truly async dispatch (virtual threads + parallel executor). With synchronous resolvers the CompletableFuture is already complete when writeAsync() runs, so the AsyncContext timer never starts — which is why this was invisible before the switch to virtual threads.

Failing test

The following test demonstrates the bug. MockAsyncContext uses a 10-second default (Tomcat's analog is 30s), and the test shows that even a timeout explicitly configured on HandlerFunctionAdapter does not reach the AsyncContext:

@Test
void asyncRequestTimeoutShouldPropagateToAsyncContext() throws Exception {
    CompletableFuture<String> resolverFuture = new CompletableFuture<>();
    GraphQlHttpHandler handler = GraphQlSetup.schemaContent("type Query { slow: String }")
            .queryFetcher("slow", (env) -> resolverFuture)
            .toHttpHandler();

    HandlerFunctionAdapter adapter = new HandlerFunctionAdapter();
    adapter.setAsyncRequestTimeout(200L);

    MockHttpServletRequest servletRequest = createServletRequest("{ slow }", "*/*");
    MockHttpServletResponse servletResponse = new MockHttpServletResponse();

    ServerRequest serverRequest = ServerRequest.create(servletRequest, MESSAGE_READERS);
    servletRequest.setAttribute(RouterFunctions.REQUEST_ATTRIBUTE, serverRequest);

    try {
        adapter.handle(servletRequest, servletResponse, handler::handleRequest);

        AsyncContext asyncContext = servletRequest.getAsyncContext();
        assertThat(asyncContext).isNotNull();
        // FAILS: actual is 10000 (MockAsyncContext default, analogous to Tomcat's 30000)
        // The 200ms configured on HandlerFunctionAdapter was silently dropped.
        assertThat(asyncContext.getTimeout()).isEqualTo(200L);
    } finally {
        resolverFuture.complete("done");
    }
}

Workaround

Explicitly bridge the gap at the container level:

@Bean
public WebServerFactoryCustomizer<ConfigurableTomcatWebServerFactory> asyncTimeoutTomcatCustomizer(
        WebMvcProperties webMvcProperties) {
    return factory -> {
        Duration timeout = webMvcProperties.getAsync().getRequestTimeout();
        if (timeout != null) {
            factory.addConnectorCustomizers(c -> c.setAsyncTimeout(timeout.toMillis()));
        }
    };
}

This requires user code to compensate for a framework gap.

Suggested fix

Two places where this could be addressed:

In spring-graphql: expose a configurable timeout and pass it to ServerResponse.async(future, timeout). This would allow the application to restore the previous no-timeout behavior or set an explicit limit:

return this.asyncRequestTimeout != null
    ? ServerResponse.async(mono.toFuture(), this.asyncRequestTimeout)
    : ServerResponse.async(mono.toFuture());

In Spring Framework (deeper fix): DefaultAsyncServerResponse.writeAsync() should not discard the AsyncWebRequest — and its configured timeout — that HandlerFunctionAdapter already installed on the WebAsyncManager. Reusing the existing request instead of creating a fresh one would fix the issue for all router-function handlers.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions