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:
@@ -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]]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user