diff --git a/spring-graphql-docs/modules/ROOT/pages/request-execution.adoc b/spring-graphql-docs/modules/ROOT/pages/request-execution.adoc index 30fed6f5..b28d0c29 100644 --- a/spring-graphql-docs/modules/ROOT/pages/request-execution.adoc +++ b/spring-graphql-docs/modules/ROOT/pages/request-execution.adoc @@ -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]] diff --git a/spring-graphql-docs/src/main/java/org/springframework/graphql/docs/execution/timeout/HttpTimeoutConfiguration.java b/spring-graphql-docs/src/main/java/org/springframework/graphql/docs/execution/timeout/HttpTimeoutConfiguration.java new file mode 100644 index 00000000..fa1de1ca --- /dev/null +++ b/spring-graphql-docs/src/main/java/org/springframework/graphql/docs/execution/timeout/HttpTimeoutConfiguration.java @@ -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)); + } + +} diff --git a/spring-graphql-docs/src/main/java/org/springframework/graphql/docs/execution/timeout/TimeoutController.java b/spring-graphql-docs/src/main/java/org/springframework/graphql/docs/execution/timeout/TimeoutController.java new file mode 100644 index 00000000..d059e0c6 --- /dev/null +++ b/spring-graphql-docs/src/main/java/org/springframework/graphql/docs/execution/timeout/TimeoutController.java @@ -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 cancel = context.get(ExecutionGraphQlRequest.CANCEL_PUBLISHER_CONTEXT_KEY); + Future 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 fetchBook(Long id) { + return null; + } + + } +} diff --git a/spring-graphql-docs/src/main/java/org/springframework/graphql/docs/execution/timeout/WebGraphQlHandlerTimeout.java b/spring-graphql-docs/src/main/java/org/springframework/graphql/docs/execution/timeout/WebGraphQlHandlerTimeout.java new file mode 100644 index 00000000..4376fe8c --- /dev/null +++ b/spring-graphql-docs/src/main/java/org/springframework/graphql/docs/execution/timeout/WebGraphQlHandlerTimeout.java @@ -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[] + } +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/server/TimeoutWebGraphQlInterceptor.java b/spring-graphql/src/main/java/org/springframework/graphql/server/TimeoutWebGraphQlInterceptor.java new file mode 100644 index 00000000..e26bfba4 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/server/TimeoutWebGraphQlInterceptor.java @@ -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. + *

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 intercept(WebGraphQlRequest request, Chain chain) { + return chain.next(request) + .timeout(this.timeout, Mono.error(new ResponseStatusException(this.timeoutStatus))); + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/server/TimeoutWebGraphQlInterceptorTests.java b/spring-graphql/src/test/java/org/springframework/graphql/server/TimeoutWebGraphQlInterceptorTests.java new file mode 100644 index 00000000..216cb8ea --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/server/TimeoutWebGraphQlInterceptorTests.java @@ -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 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 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 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 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); + } + } + +}