diff --git a/core/src/main/resources/restheart-default-config-no-mongodb.yml b/core/src/main/resources/restheart-default-config-no-mongodb.yml index aa4900e1e..d6631e965 100644 --- a/core/src/main/resources/restheart-default-config-no-mongodb.yml +++ b/core/src/main/resources/restheart-default-config-no-mongodb.yml @@ -276,9 +276,9 @@ mongo: # TTL in milliseconds; specify a value < 0 to never expire cached entries schema-cache-ttl: 60000 - # Time limit in milliseconds for processing queries on the server (without network latency). 0 means no time limit + # The time limit in milliseconds for processing queries. Set to 0 for no time limit. query-time-limit: 0 - # Time limit in milliseconds for processing aggregations on the server (without network latency). 0 means no time limit + # The time limit in milliseconds for processing aggregations. Set to 0 for no time limit. aggregation-time-limit: 0 changeStreamActivator: @@ -306,6 +306,8 @@ graphql: default-limit: 100 # max-limit is the maximum value for a Query limit max-limit: 1000 + # The time limit in milliseconds for processing queries. Set to 0 for no time limit. + query-time-limit: 0 verbose: false cacheInvalidator: diff --git a/core/src/main/resources/restheart-default-config.yml b/core/src/main/resources/restheart-default-config.yml index b890114b6..5e5d60a33 100644 --- a/core/src/main/resources/restheart-default-config.yml +++ b/core/src/main/resources/restheart-default-config.yml @@ -275,9 +275,9 @@ mongo: # TTL in milliseconds; specify a value < 0 to never expire cached entries schema-cache-ttl: 60000 - # Time limit in milliseconds for processing queries on the server (without network latency). 0 means no time limit + # The time limit in milliseconds for processing queries. Set to 0 for no time limit. query-time-limit: 0 - # Time limit in milliseconds for processing aggregations on the server (without network latency). 0 means no time limit + # The time limit in milliseconds for processing aggregations. Set to 0 for no time limit. aggregation-time-limit: 0 # Deprecated: it will be removed in RH v8.0 @@ -298,6 +298,8 @@ graphql: default-limit: 100 # max-limit is the maximum value for a Query limit max-limit: 1000 + # The time limit in milliseconds for processing queries. Set to 0 for no time limit. + query-time-limit: 0 verbose: false # Proxied resources - expose exrernal API with RESTHeart acting as a reverese proxy diff --git a/graphql/src/main/java/org/restheart/graphql/GraphQLService.java b/graphql/src/main/java/org/restheart/graphql/GraphQLService.java index b78ae14d6..7cac8c4f1 100644 --- a/graphql/src/main/java/org/restheart/graphql/GraphQLService.java +++ b/graphql/src/main/java/org/restheart/graphql/GraphQLService.java @@ -44,6 +44,8 @@ import org.restheart.graphql.datafetchers.GraphQLDataFetcher; import org.restheart.graphql.dataloaders.AggregationBatchLoader; import org.restheart.graphql.dataloaders.QueryBatchLoader; +import org.restheart.graphql.instrumentation.MaxQueryTimeInstrumentation; +import org.restheart.graphql.instrumentation.QueryTimeoutException; import org.restheart.graphql.models.AggregationMapping; import org.restheart.graphql.models.GraphQLApp; import org.restheart.graphql.models.QueryMapping; @@ -90,6 +92,7 @@ public class GraphQLService implements Service public static final Boolean DEFAULT_VERBOSE = false; public static final int DEFAULT_DEFAULT_LIMIT = 100; public static final int DEFAULT_MAX_LIMIT = 1_000; + public static final int DEFAULT_QUERY_TIME_LIMIT = 0; // disabled private static final Logger LOGGER = LoggerFactory.getLogger(GraphQLService.class); @@ -99,6 +102,7 @@ public class GraphQLService implements Service private Boolean verbose = DEFAULT_VERBOSE; private int defaultLimit = DEFAULT_DEFAULT_LIMIT; private int maxLimit = DEFAULT_MAX_LIMIT; + private int queryTimeLimit = DEFAULT_QUERY_TIME_LIMIT; @Inject("mclient") private MongoClient mclient; @@ -115,8 +119,9 @@ public void init()throws ConfigurationException, NoSuchFieldException, IllegalAc this.verbose = argOrDefault(config, "verbose", DEFAULT_VERBOSE); this.verbose = argOrDefault(config, "verbose", DEFAULT_VERBOSE); - this.defaultLimit = argOrDefault(config, "default-limit", 100); - this.maxLimit = argOrDefault(config, "max-limit", 1000); + this.defaultLimit = argOrDefault(config, "default-limit", DEFAULT_DEFAULT_LIMIT); + this.maxLimit = argOrDefault(config, "max-limit", DEFAULT_MAX_LIMIT); + this.queryTimeLimit = argOrDefault(config, "query-time-limit", DEFAULT_QUERY_TIME_LIMIT); AppDefinitionLoadingCache.setTTL(argOrDefault(config, "app-def-cache-ttl", 1_000)); @@ -190,8 +195,12 @@ public void handle(GraphQLRequest req, GraphQLResponse res) throws Exception { } var dispatcherInstrumentation = new DataLoaderDispatcherInstrumentation(dispatcherInstrumentationOptions); + var maxQueryTimeInstrumentation = new MaxQueryTimeInstrumentation(this.queryTimeLimit); - this.gql = GraphQL.newGraphQL(graphQLApp.getExecutableSchema()).instrumentation(dispatcherInstrumentation).build(); + this.gql = GraphQL.newGraphQL(graphQLApp.getExecutableSchema()) + .instrumentation(dispatcherInstrumentation) + .instrumentation(maxQueryTimeInstrumentation) + .build(); try { var result = this.gql.execute(inputBuilder.build()); @@ -204,7 +213,11 @@ public void handle(GraphQLRequest req, GraphQLResponse res) throws Exception { // The graphql specification specifies: // If an error was encountered during the execution that prevented a valid response, the data entry in the response should be null." if (result.getErrors() != null && !result.getErrors().isEmpty() && result.getData() == null) { - res.setStatusCode(HttpStatus.SC_BAD_REQUEST); + if (result.getErrors().stream().anyMatch(e -> e instanceof QueryTimeoutException)) { + res.setStatusCode(HttpStatus.SC_REQUEST_TIMEOUT); + } else { + res.setStatusCode(HttpStatus.SC_BAD_REQUEST); + } } if (this.verbose) { diff --git a/graphql/src/main/java/org/restheart/graphql/instrumentation/MaxQueryTimeInstrumentation.java b/graphql/src/main/java/org/restheart/graphql/instrumentation/MaxQueryTimeInstrumentation.java new file mode 100644 index 000000000..c533c8e5e --- /dev/null +++ b/graphql/src/main/java/org/restheart/graphql/instrumentation/MaxQueryTimeInstrumentation.java @@ -0,0 +1,71 @@ +package org.restheart.graphql.instrumentation; + + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import graphql.ExecutionResult; +import graphql.execution.AbortExecutionException; +import graphql.execution.instrumentation.InstrumentationContext; +import graphql.execution.instrumentation.InstrumentationState; +import static graphql.execution.instrumentation.SimpleInstrumentationContext.noOp; +import graphql.execution.instrumentation.SimplePerformantInstrumentation; +import graphql.execution.instrumentation.parameters.InstrumentationCreateStateParameters; +import graphql.execution.instrumentation.parameters.InstrumentationFieldParameters; + +/** + * Aborts the execution if the query time is greater than the specified queryTimeLimit. + */ +public class MaxQueryTimeInstrumentation extends SimplePerformantInstrumentation { + + private static final Logger LOGGER = LoggerFactory.getLogger(MaxQueryTimeInstrumentation.class); + + private final int queryTimeLimit; // in ms + + /** + * Creates a new instrumentation that aborts the execution if the query time is greater than the specified queryTimeLimit + * + * @param queryTimeLimit max allowed query time, otherwise execution will be aborted. Set queryTimeLimit <= 0 to disable it. + */ + public MaxQueryTimeInstrumentation(int queryTimeLimit) { + this.queryTimeLimit = queryTimeLimit; + } + + @Override + public InstrumentationState createState(InstrumentationCreateStateParameters parameters) { + return new State(System.currentTimeMillis()); + } + + @Override + public InstrumentationContext beginField(InstrumentationFieldParameters parameters, InstrumentationState rawState) { + if (this.queryTimeLimit <= 0) { + return noOp(); + } + + State state = InstrumentationState.ofState(rawState); + var elapsed = System.currentTimeMillis() - state.startTime; + + + if (elapsed > this.queryTimeLimit) { + LOGGER.debug("Exceeded query time limit of {} while attempting to fetch field '{}'. Current query elapsed time: {}.", this.queryTimeLimit, parameters.getExecutionStepInfo().getPath(), elapsed); + throw mkAbortException(this.queryTimeLimit); + } else { + LOGGER.trace("Fetching data for field '{}' initiated. Current elapsed query time: {}.", parameters.getExecutionStepInfo().getPath(), elapsed); + return noOp(); + } + } + + /** + * Generate the exception with error message + * + * @param maxTime the maximum time allowed + * + * @return an instance of AbortExecutionException + */ + protected AbortExecutionException mkAbortException(long maxTime) { + return new QueryTimeoutException("Maximum query time limit of " + maxTime + "ms exceeded"); + } + + private static record State(long startTime) implements InstrumentationState { + } +} \ No newline at end of file diff --git a/graphql/src/main/java/org/restheart/graphql/instrumentation/QueryTimeoutException.java b/graphql/src/main/java/org/restheart/graphql/instrumentation/QueryTimeoutException.java new file mode 100644 index 000000000..913ae03b1 --- /dev/null +++ b/graphql/src/main/java/org/restheart/graphql/instrumentation/QueryTimeoutException.java @@ -0,0 +1,18 @@ +/* + * Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license + * Click nbfs://nbhost/SystemFileSystem/Templates/Classes/Class.java to edit this template + */ + +package org.restheart.graphql.instrumentation; + +import graphql.execution.AbortExecutionException; + +/** + * + * @author uji + */ +public class QueryTimeoutException extends AbortExecutionException { + public QueryTimeoutException(String message) { + super(message); + } +}