Add TimeoutWebGraphQlInterceptor

This commit adds a new web interceptor that can be configured with a
specific duration. If the response is not produced within this timeline,
the interceptor sends a HTTP error status to the client (by default,
"REQUEST TIMEOUT" but this can be configured) and sends a CANCEL signal
upstream.
This CANCEL signal flows up to controller methods and maually registered
data fetchers, if they have a reactive return type. Processing will be
automatically aborted.
For other types of date fetchers, applications can retrieve a publisher
from the GraphQL context and get notified of cancellations.

Closes gh-450
This commit is contained in:
Brian Clozel
2025-03-12 17:00:18 +01:00
parent 0df247fa38
commit 6d471cd7b8
6 changed files with 334 additions and 0 deletions

View File

@@ -395,6 +395,35 @@ immediately after the GraphQL Java engine returns, which would be the case if th
request is simple enough and did not require asynchronous data fetching.
[[execution.timeout]]
== GraphQL Request Timeout
GraphQL clients can send requests that will consume lots of resources on the server side.
There are many ways to protect against this, and one of them is to configure a request timeout.
This ensures that requests are closed on the server side if the response takes too long to materialize.
Spring for GraphQL provides a `TimeoutWebGraphQlInterceptor` for the web transports.
Applications can configure this interceptor with a timeout duration; if the request times out, the server errors with a specific HTTP status.
In this case, the interceptor will send a "cancel" signal up the chain and reactive data fetchers will automatically cancel any ongoing work.
This interceptor can be configured on the `WebGraphQlHandler`:
include-code::WebGraphQlHandlerTimeout[tag=interceptor,indent=0]
In a Spring Boot application, contributing the interceptor as a bean is enough:
include-code::HttpTimeoutConfiguration[]
For more transport-specific timeouts, there are dedicated properties on the handler implementations like
`GraphQlWebSocketHandler` and `GraphQlSseHandler`.
NOTE: While reactive data fetchers are cancelled automatically, this cannot be done for others
as there is no consistent way to cancel processing. In this case, controller methods can get
the cancellation signal from a `Mono` in the GraphQL context and manually cancel work.
Here is an example of using the cancellation signal to abort processing inside a controller method:
include-code::TimeoutController[tag=cancel,indent=0]
[[execution.reactivedatafetcher]]

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2020-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.docs.execution.timeout;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.graphql.server.TimeoutWebGraphQlInterceptor;
@Configuration(proxyBeanMethods = false)
public class HttpTimeoutConfiguration {
@Bean
public TimeoutWebGraphQlInterceptor timeoutWebGraphQlInterceptor() {
return new TimeoutWebGraphQlInterceptor(Duration.ofSeconds(5));
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2020-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.docs.execution.timeout;
import java.util.concurrent.Future;
import graphql.GraphQLContext;
import reactor.core.publisher.Mono;
import org.springframework.graphql.ExecutionGraphQlRequest;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;
@Controller
public class TimeoutController {
BookCache bookCache = new BookCache();
// tag::cancel[]
@QueryMapping
public Book bookById(@Argument Long id, GraphQLContext context) throws Exception {
Mono<Void> cancel = context.get(ExecutionGraphQlRequest.CANCEL_PUBLISHER_CONTEXT_KEY);
Future<Book> bookFuture = this.bookCache.fetchBook(id);
cancel.doOnCancel(() -> bookFuture.cancel(true)).subscribe();
return bookFuture.get();
}
// end::cancel[]
record Book(String title, String author) {
}
class BookCache {
public Future<Book> fetchBook(Long id) {
return null;
}
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2020-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.docs.execution.timeout;
import java.time.Duration;
import org.springframework.graphql.execution.DefaultExecutionGraphQlService;
import org.springframework.graphql.execution.GraphQlSource;
import org.springframework.graphql.server.TimeoutWebGraphQlInterceptor;
import org.springframework.graphql.server.WebGraphQlHandler;
import org.springframework.graphql.server.webmvc.GraphQlHttpHandler;
public class WebGraphQlHandlerTimeout {
void configureWebGraphQlHandler() {
GraphQlSource graphQlSource = GraphQlSource.schemaResourceBuilder().build();
DefaultExecutionGraphQlService executionGraphQlService = new DefaultExecutionGraphQlService(graphQlSource);
// tag::interceptor[]
TimeoutWebGraphQlInterceptor timeoutInterceptor = new TimeoutWebGraphQlInterceptor(Duration.ofSeconds(5));
WebGraphQlHandler webGraphQlHandler = WebGraphQlHandler
.builder(executionGraphQlService)
.interceptor(timeoutInterceptor)
.build();
GraphQlHttpHandler httpHandler = new GraphQlHttpHandler(webGraphQlHandler);
// end::interceptor[]
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2020-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.server;
import java.time.Duration;
import reactor.core.publisher.Mono;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
/**
* {@link WebGraphQlInterceptor Web interceptor} that enforces a request timeout
* for GraphQL requests. By default, timeouts will result in
* {@link HttpStatus#REQUEST_TIMEOUT} responses.
* <p>For streaming responses (like subscriptions), this timeout is only enforced
* until the response stream is established. Transport-specific timeouts are
* configurable on the transport handlers directly.
* @author Brian Clozel
* @since 1.4
*/
public class TimeoutWebGraphQlInterceptor implements WebGraphQlInterceptor {
private final Duration timeout;
private final HttpStatus timeoutStatus;
/**
* Create a new interceptor for the given timeout duration.
* @param timeout the request timeout to enforce
*/
public TimeoutWebGraphQlInterceptor(Duration timeout) {
this(timeout, HttpStatus.REQUEST_TIMEOUT);
}
/**
* Create a new interceptor for the given timeout duration and response status.
* @param timeout the request timeout to enforce
* @param timeoutStatus the HTTP response status to use in case of timeouts
*/
public TimeoutWebGraphQlInterceptor(Duration timeout, HttpStatus timeoutStatus) {
this.timeout = timeout;
this.timeoutStatus = timeoutStatus;
}
@Override
public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {
return chain.next(request)
.timeout(this.timeout, Mono.error(new ResponseStatusException(this.timeoutStatus)));
}
}

View File

@@ -0,0 +1,108 @@
/*
* Copyright 2020-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.server;
import java.net.URI;
import java.time.Duration;
import java.util.Map;
import graphql.ExecutionInput;
import graphql.ExecutionResult;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import org.springframework.graphql.ExecutionGraphQlResponse;
import org.springframework.graphql.support.DefaultExecutionGraphQlResponse;
import org.springframework.graphql.support.DefaultGraphQlRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link TimeoutWebGraphQlInterceptor}.
*/
class TimeoutWebGraphQlInterceptorTests {
@Test
void shouldRespondWhenTimeoutNotExceeded() {
TimeoutWebGraphQlInterceptor interceptor = new TimeoutWebGraphQlInterceptor(Duration.ofSeconds(3));
TestChain interceptorChain = new TestChain(Duration.ofMillis(200));
Mono<WebGraphQlResponse> response = interceptor.intercept(createRequest(), interceptorChain);
StepVerifier.create(response).expectNextCount(1).expectComplete().verify();
assertThat(interceptorChain.cancelled).isFalse();
}
@Test
void shouldTimeoutWithDefaultStatus() {
TimeoutWebGraphQlInterceptor interceptor = new TimeoutWebGraphQlInterceptor(Duration.ofMillis(200));
TestChain interceptorChain = new TestChain(Duration.ofSeconds(1));
Mono<WebGraphQlResponse> response = interceptor.intercept(createRequest(), interceptorChain);
StepVerifier.create(response).expectErrorSatisfies(error -> {
assertThat(error).isInstanceOf(ResponseStatusException.class);
ResponseStatusException responseStatusException = (ResponseStatusException) error;
assertThat(responseStatusException.getStatusCode()).isEqualTo(HttpStatus.REQUEST_TIMEOUT);
}).verify();
assertThat(interceptorChain.cancelled).isTrue();
}
@Test
void shouldTimeoutWithCustomStatus() {
TimeoutWebGraphQlInterceptor interceptor = new TimeoutWebGraphQlInterceptor(Duration.ofMillis(200), HttpStatus.GATEWAY_TIMEOUT);
TestChain interceptorChain = new TestChain(Duration.ofSeconds(1));
Mono<WebGraphQlResponse> response = interceptor.intercept(createRequest(), interceptorChain);
StepVerifier.create(response).expectErrorSatisfies(error -> {
assertThat(error).isInstanceOf(ResponseStatusException.class);
ResponseStatusException responseStatusException = (ResponseStatusException) error;
assertThat(responseStatusException.getStatusCode()).isEqualTo(HttpStatus.GATEWAY_TIMEOUT);
}).verify();
assertThat(interceptorChain.cancelled).isTrue();
}
WebGraphQlRequest createRequest() {
return new WebGraphQlRequest(URI.create("https://localhost/graphql"), new HttpHeaders(),
null, null, Map.of(), new DefaultGraphQlRequest("{ greeting }"), "id", null);
}
class TestChain implements WebGraphQlInterceptor.Chain {
private Duration delay;
boolean cancelled;
public TestChain(Duration delay) {
this.delay = delay;
}
@Override
public Mono<WebGraphQlResponse> next(WebGraphQlRequest request) {
ExecutionInput executionInput = ExecutionInput.newExecutionInput().query("{ greeting }").build();
ExecutionResult executionResult = ExecutionResult.newExecutionResult().data("Hello World").build();
ExecutionGraphQlResponse response = new DefaultExecutionGraphQlResponse(executionInput, executionResult);
return Mono.just(new WebGraphQlResponse(response))
.delayElement(this.delay)
.doOnCancel(() -> this.cancelled = true);
}
}
}