Skip to content

Commit 6d471cd

Browse files
committed
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
1 parent 0df247f commit 6d471cd

File tree

6 files changed

+334
-0
lines changed

6 files changed

+334
-0
lines changed

spring-graphql-docs/modules/ROOT/pages/request-execution.adoc

+29
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,35 @@ immediately after the GraphQL Java engine returns, which would be the case if th
395395
request is simple enough and did not require asynchronous data fetching.
396396

397397

398+
[[execution.timeout]]
399+
== GraphQL Request Timeout
400+
401+
GraphQL clients can send requests that will consume lots of resources on the server side.
402+
There are many ways to protect against this, and one of them is to configure a request timeout.
403+
This ensures that requests are closed on the server side if the response takes too long to materialize.
404+
405+
Spring for GraphQL provides a `TimeoutWebGraphQlInterceptor` for the web transports.
406+
Applications can configure this interceptor with a timeout duration; if the request times out, the server errors with a specific HTTP status.
407+
In this case, the interceptor will send a "cancel" signal up the chain and reactive data fetchers will automatically cancel any ongoing work.
408+
409+
This interceptor can be configured on the `WebGraphQlHandler`:
410+
411+
include-code::WebGraphQlHandlerTimeout[tag=interceptor,indent=0]
412+
413+
In a Spring Boot application, contributing the interceptor as a bean is enough:
414+
415+
include-code::HttpTimeoutConfiguration[]
416+
417+
For more transport-specific timeouts, there are dedicated properties on the handler implementations like
418+
`GraphQlWebSocketHandler` and `GraphQlSseHandler`.
419+
420+
NOTE: While reactive data fetchers are cancelled automatically, this cannot be done for others
421+
as there is no consistent way to cancel processing. In this case, controller methods can get
422+
the cancellation signal from a `Mono` in the GraphQL context and manually cancel work.
423+
424+
Here is an example of using the cancellation signal to abort processing inside a controller method:
425+
426+
include-code::TimeoutController[tag=cancel,indent=0]
398427

399428

400429
[[execution.reactivedatafetcher]]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2020-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.graphql.docs.execution.timeout;
18+
19+
import java.time.Duration;
20+
21+
import org.springframework.context.annotation.Bean;
22+
import org.springframework.context.annotation.Configuration;
23+
import org.springframework.graphql.server.TimeoutWebGraphQlInterceptor;
24+
25+
@Configuration(proxyBeanMethods = false)
26+
public class HttpTimeoutConfiguration {
27+
28+
@Bean
29+
public TimeoutWebGraphQlInterceptor timeoutWebGraphQlInterceptor() {
30+
return new TimeoutWebGraphQlInterceptor(Duration.ofSeconds(5));
31+
}
32+
33+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright 2020-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.graphql.docs.execution.timeout;
18+
19+
import java.util.concurrent.Future;
20+
21+
import graphql.GraphQLContext;
22+
import reactor.core.publisher.Mono;
23+
24+
import org.springframework.graphql.ExecutionGraphQlRequest;
25+
import org.springframework.graphql.data.method.annotation.Argument;
26+
import org.springframework.graphql.data.method.annotation.QueryMapping;
27+
import org.springframework.stereotype.Controller;
28+
29+
@Controller
30+
public class TimeoutController {
31+
32+
BookCache bookCache = new BookCache();
33+
34+
// tag::cancel[]
35+
@QueryMapping
36+
public Book bookById(@Argument Long id, GraphQLContext context) throws Exception {
37+
38+
Mono<Void> cancel = context.get(ExecutionGraphQlRequest.CANCEL_PUBLISHER_CONTEXT_KEY);
39+
Future<Book> bookFuture = this.bookCache.fetchBook(id);
40+
cancel.doOnCancel(() -> bookFuture.cancel(true)).subscribe();
41+
return bookFuture.get();
42+
}
43+
// end::cancel[]
44+
45+
record Book(String title, String author) {
46+
47+
}
48+
49+
class BookCache {
50+
51+
public Future<Book> fetchBook(Long id) {
52+
return null;
53+
}
54+
55+
}
56+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright 2020-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.graphql.docs.execution.timeout;
18+
19+
import java.time.Duration;
20+
21+
import org.springframework.graphql.execution.DefaultExecutionGraphQlService;
22+
import org.springframework.graphql.execution.GraphQlSource;
23+
import org.springframework.graphql.server.TimeoutWebGraphQlInterceptor;
24+
import org.springframework.graphql.server.WebGraphQlHandler;
25+
import org.springframework.graphql.server.webmvc.GraphQlHttpHandler;
26+
27+
public class WebGraphQlHandlerTimeout {
28+
29+
void configureWebGraphQlHandler() {
30+
GraphQlSource graphQlSource = GraphQlSource.schemaResourceBuilder().build();
31+
DefaultExecutionGraphQlService executionGraphQlService = new DefaultExecutionGraphQlService(graphQlSource);
32+
33+
// tag::interceptor[]
34+
TimeoutWebGraphQlInterceptor timeoutInterceptor = new TimeoutWebGraphQlInterceptor(Duration.ofSeconds(5));
35+
WebGraphQlHandler webGraphQlHandler = WebGraphQlHandler
36+
.builder(executionGraphQlService)
37+
.interceptor(timeoutInterceptor)
38+
.build();
39+
GraphQlHttpHandler httpHandler = new GraphQlHttpHandler(webGraphQlHandler);
40+
// end::interceptor[]
41+
}
42+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright 2020-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.graphql.server;
18+
19+
import java.time.Duration;
20+
21+
import reactor.core.publisher.Mono;
22+
23+
import org.springframework.http.HttpStatus;
24+
import org.springframework.web.server.ResponseStatusException;
25+
26+
/**
27+
* {@link WebGraphQlInterceptor Web interceptor} that enforces a request timeout
28+
* for GraphQL requests. By default, timeouts will result in
29+
* {@link HttpStatus#REQUEST_TIMEOUT} responses.
30+
* <p>For streaming responses (like subscriptions), this timeout is only enforced
31+
* until the response stream is established. Transport-specific timeouts are
32+
* configurable on the transport handlers directly.
33+
* @author Brian Clozel
34+
* @since 1.4
35+
*/
36+
public class TimeoutWebGraphQlInterceptor implements WebGraphQlInterceptor {
37+
38+
private final Duration timeout;
39+
40+
private final HttpStatus timeoutStatus;
41+
42+
/**
43+
* Create a new interceptor for the given timeout duration.
44+
* @param timeout the request timeout to enforce
45+
*/
46+
public TimeoutWebGraphQlInterceptor(Duration timeout) {
47+
this(timeout, HttpStatus.REQUEST_TIMEOUT);
48+
}
49+
50+
/**
51+
* Create a new interceptor for the given timeout duration and response status.
52+
* @param timeout the request timeout to enforce
53+
* @param timeoutStatus the HTTP response status to use in case of timeouts
54+
*/
55+
public TimeoutWebGraphQlInterceptor(Duration timeout, HttpStatus timeoutStatus) {
56+
this.timeout = timeout;
57+
this.timeoutStatus = timeoutStatus;
58+
}
59+
60+
@Override
61+
public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {
62+
return chain.next(request)
63+
.timeout(this.timeout, Mono.error(new ResponseStatusException(this.timeoutStatus)));
64+
}
65+
66+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Copyright 2020-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.graphql.server;
18+
19+
20+
import java.net.URI;
21+
import java.time.Duration;
22+
import java.util.Map;
23+
24+
import graphql.ExecutionInput;
25+
import graphql.ExecutionResult;
26+
import org.junit.jupiter.api.Test;
27+
import reactor.core.publisher.Mono;
28+
import reactor.test.StepVerifier;
29+
30+
import org.springframework.graphql.ExecutionGraphQlResponse;
31+
import org.springframework.graphql.support.DefaultExecutionGraphQlResponse;
32+
import org.springframework.graphql.support.DefaultGraphQlRequest;
33+
import org.springframework.http.HttpHeaders;
34+
import org.springframework.http.HttpStatus;
35+
import org.springframework.web.server.ResponseStatusException;
36+
37+
import static org.assertj.core.api.Assertions.assertThat;
38+
39+
/**
40+
* Tests for {@link TimeoutWebGraphQlInterceptor}.
41+
*/
42+
class TimeoutWebGraphQlInterceptorTests {
43+
44+
@Test
45+
void shouldRespondWhenTimeoutNotExceeded() {
46+
TimeoutWebGraphQlInterceptor interceptor = new TimeoutWebGraphQlInterceptor(Duration.ofSeconds(3));
47+
TestChain interceptorChain = new TestChain(Duration.ofMillis(200));
48+
Mono<WebGraphQlResponse> response = interceptor.intercept(createRequest(), interceptorChain);
49+
50+
StepVerifier.create(response).expectNextCount(1).expectComplete().verify();
51+
assertThat(interceptorChain.cancelled).isFalse();
52+
}
53+
54+
@Test
55+
void shouldTimeoutWithDefaultStatus() {
56+
TimeoutWebGraphQlInterceptor interceptor = new TimeoutWebGraphQlInterceptor(Duration.ofMillis(200));
57+
TestChain interceptorChain = new TestChain(Duration.ofSeconds(1));
58+
Mono<WebGraphQlResponse> response = interceptor.intercept(createRequest(), interceptorChain);
59+
60+
StepVerifier.create(response).expectErrorSatisfies(error -> {
61+
assertThat(error).isInstanceOf(ResponseStatusException.class);
62+
ResponseStatusException responseStatusException = (ResponseStatusException) error;
63+
assertThat(responseStatusException.getStatusCode()).isEqualTo(HttpStatus.REQUEST_TIMEOUT);
64+
}).verify();
65+
assertThat(interceptorChain.cancelled).isTrue();
66+
}
67+
68+
@Test
69+
void shouldTimeoutWithCustomStatus() {
70+
TimeoutWebGraphQlInterceptor interceptor = new TimeoutWebGraphQlInterceptor(Duration.ofMillis(200), HttpStatus.GATEWAY_TIMEOUT);
71+
TestChain interceptorChain = new TestChain(Duration.ofSeconds(1));
72+
Mono<WebGraphQlResponse> response = interceptor.intercept(createRequest(), interceptorChain);
73+
74+
StepVerifier.create(response).expectErrorSatisfies(error -> {
75+
assertThat(error).isInstanceOf(ResponseStatusException.class);
76+
ResponseStatusException responseStatusException = (ResponseStatusException) error;
77+
assertThat(responseStatusException.getStatusCode()).isEqualTo(HttpStatus.GATEWAY_TIMEOUT);
78+
}).verify();
79+
assertThat(interceptorChain.cancelled).isTrue();
80+
}
81+
82+
WebGraphQlRequest createRequest() {
83+
return new WebGraphQlRequest(URI.create("https://localhost/graphql"), new HttpHeaders(),
84+
null, null, Map.of(), new DefaultGraphQlRequest("{ greeting }"), "id", null);
85+
}
86+
87+
class TestChain implements WebGraphQlInterceptor.Chain {
88+
89+
private Duration delay;
90+
91+
boolean cancelled;
92+
93+
public TestChain(Duration delay) {
94+
this.delay = delay;
95+
}
96+
97+
@Override
98+
public Mono<WebGraphQlResponse> next(WebGraphQlRequest request) {
99+
ExecutionInput executionInput = ExecutionInput.newExecutionInput().query("{ greeting }").build();
100+
ExecutionResult executionResult = ExecutionResult.newExecutionResult().data("Hello World").build();
101+
ExecutionGraphQlResponse response = new DefaultExecutionGraphQlResponse(executionInput, executionResult);
102+
return Mono.just(new WebGraphQlResponse(response))
103+
.delayElement(this.delay)
104+
.doOnCancel(() -> this.cancelled = true);
105+
}
106+
}
107+
108+
}

0 commit comments

Comments
 (0)