Skip to content

Commit d7d2870

Browse files
committed
GH-5723: propagate maxExecutionTime as per-request HTTP response timeout
When setMaxExecutionTime(int) is called on an HTTP-based query or update, the value is now applied as a per-request response timeout on the HTTP connection, enforced client-side in addition to being sent as a server-sidehint. HttpRequest gains an optional responseTimeout field (Duration). Both SPARQLProtocolSession and RDF4JProtocolSession (which overrides getQueryMethod/getUpdateMethod) set this field when maxQueryTime > 0. The Apache HC5 client applies it via RequestConfig#setResponseTimeout, overriding the client-level socket timeout for that specific request. The JDK client applies it via HttpRequest.Builder#timeout, with the per-request value taking precedence over the global socketTimeoutMs from RDF4JHttpClientConfig.
1 parent 1171b98 commit d7d2870

7 files changed

Lines changed: 87 additions & 5 deletions

File tree

core/http/client-apache5/src/main/java/org/eclipse/rdf4j/http/client/apache5/ApacheHC5RDF4JHttpClient.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@
1818
import org.apache.hc.client5.http.classic.methods.HttpPost;
1919
import org.apache.hc.client5.http.classic.methods.HttpPut;
2020
import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase;
21+
import org.apache.hc.client5.http.config.RequestConfig;
2122
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
2223
import org.apache.hc.core5.http.ClassicHttpResponse;
2324
import org.apache.hc.core5.http.ContentType;
2425
import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
2526
import org.apache.hc.core5.http.io.entity.InputStreamEntity;
27+
import org.apache.hc.core5.util.Timeout;
2628
import org.eclipse.rdf4j.http.client.spi.HttpHeader;
2729
import org.eclipse.rdf4j.http.client.spi.HttpRequest;
2830
import org.eclipse.rdf4j.http.client.spi.HttpRequestBody;
@@ -101,6 +103,11 @@ private HttpUriRequestBase buildRequest(HttpRequest request) throws IOException
101103
hcRequest.addHeader(header.getName(), header.getValue());
102104
}
103105

106+
// Apply per-request response timeout if present
107+
request.getResponseTimeout()
108+
.ifPresent(timeout -> hcRequest
109+
.setConfig(RequestConfig.custom().setResponseTimeout(Timeout.of(timeout)).build()));
110+
104111
// Set body. Repeatable (byte-backed) bodies use ByteArrayEntity so that Apache HC5's
105112
// RedirectExec can follow redirects and the 408 retry loop can re-send the request.
106113
// Single-use stream bodies use InputStreamEntity (non-repeatable); RedirectExec will

core/http/client-api/src/main/java/org/eclipse/rdf4j/http/client/spi/HttpRequest.java

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
package org.eclipse.rdf4j.http.client.spi;
1212

1313
import java.net.URI;
14+
import java.time.Duration;
1415
import java.util.ArrayList;
1516
import java.util.List;
1617
import java.util.Map;
@@ -48,12 +49,14 @@ public class HttpRequest {
4849
private final URI uri;
4950
private final List<HttpHeader> headers;
5051
private final HttpRequestBody body;
52+
private final Duration responseTimeout;
5153

5254
private HttpRequest(Builder builder) {
5355
this.method = builder.method;
5456
this.uri = builder.uri;
5557
this.headers = new ArrayList<>(builder.headers);
5658
this.body = builder.body;
59+
this.responseTimeout = builder.responseTimeout;
5760
}
5861

5962
/**
@@ -142,6 +145,17 @@ public Optional<HttpRequestBody> getBody() {
142145
return Optional.ofNullable(body);
143146
}
144147

148+
/**
149+
* Returns the optional response timeout for this request. When present, the HTTP client must abort the request if
150+
* no response data arrives within this duration. Overrides any client-level socket timeout for this specific
151+
* request.
152+
*
153+
* @return an {@link Optional} containing the response timeout, or empty if no per-request timeout is set
154+
*/
155+
public Optional<Duration> getResponseTimeout() {
156+
return Optional.ofNullable(responseTimeout);
157+
}
158+
145159
/**
146160
* Creates a new {@link Builder} for the given HTTP method and target URI.
147161
*
@@ -165,7 +179,8 @@ public static Builder newBuilder(String method, URI uri) {
165179
public static Builder copyOf(HttpRequest original, URI uri) {
166180
return new Builder(original.method, uri)
167181
.headers(original.headers)
168-
.body(original.body);
182+
.body(original.body)
183+
.responseTimeout(original.responseTimeout);
169184
}
170185

171186
/**
@@ -181,6 +196,7 @@ public static final class Builder {
181196
private final URI uri;
182197
private final List<HttpHeader> headers = new ArrayList<>();
183198
private HttpRequestBody body;
199+
private Duration responseTimeout;
184200

185201
private Builder(String method, URI uri) {
186202
this.method = method;
@@ -236,6 +252,19 @@ public Builder body(HttpRequestBody body) {
236252
return this;
237253
}
238254

255+
/**
256+
* Sets the response timeout for this request. When set, the HTTP client must abort the request if no response
257+
* data arrives within this duration, overriding any client-level socket timeout. Pass {@code null} to clear a
258+
* previously set timeout.
259+
*
260+
* @param timeout the response timeout, or {@code null} for no per-request timeout
261+
* @return this builder
262+
*/
263+
public Builder responseTimeout(Duration timeout) {
264+
this.responseTimeout = timeout;
265+
return this;
266+
}
267+
239268
/**
240269
* Builds and returns the immutable {@link HttpRequest}.
241270
*

core/http/client-jdk/src/main/java/org/eclipse/rdf4j/http/client/jdk/JdkRDF4JHttpClient.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,10 @@ public org.eclipse.rdf4j.http.client.spi.HttpResponse execute(org.eclipse.rdf4j.
5353
builder.header(header.getName(), header.getValue());
5454
}
5555

56-
// Set socket timeout if configured
57-
if (config.getSocketTimeoutMs() > 0) {
56+
// Per-request response timeout takes precedence over the global socket timeout from config
57+
if (request.getResponseTimeout().isPresent()) {
58+
builder.timeout(request.getResponseTimeout().get());
59+
} else if (config.getSocketTimeoutMs() > 0) {
5860
builder.timeout(Duration.ofMillis(config.getSocketTimeoutMs()));
5961
}
6062

core/http/client/src/main/java/org/eclipse/rdf4j/http/client/RDF4JProtocolSession.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.net.URI;
2323
import java.nio.charset.Charset;
2424
import java.nio.charset.StandardCharsets;
25+
import java.time.Duration;
2526
import java.util.ArrayList;
2627
import java.util.List;
2728
import java.util.Locale;
@@ -855,6 +856,9 @@ protected HttpRequest getQueryMethod(QueryLanguage ql, String query, String base
855856
for (Map.Entry<String, String> additionalHeader : getAdditionalHttpHeaders().entrySet()) {
856857
builder.header(additionalHeader.getKey(), additionalHeader.getValue());
857858
}
859+
if (maxQueryTime > 0) {
860+
builder.responseTimeout(Duration.ofSeconds(maxQueryTime));
861+
}
858862
return builder.build();
859863
}
860864

@@ -918,6 +922,9 @@ protected HttpRequest getUpdateMethod(QueryLanguage ql, String update, String ba
918922
for (Map.Entry<String, String> additionalHeader : getAdditionalHttpHeaders().entrySet()) {
919923
builder.header(additionalHeader.getKey(), additionalHeader.getValue());
920924
}
925+
if (maxExecutionTime > 0) {
926+
builder.responseTimeout(Duration.ofSeconds(maxExecutionTime));
927+
}
921928
return builder.build();
922929
}
923930

core/http/client/src/main/java/org/eclipse/rdf4j/http/client/SPARQLProtocolSession.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.net.HttpURLConnection;
2323
import java.nio.charset.Charset;
2424
import java.nio.charset.StandardCharsets;
25+
import java.time.Duration;
2526
import java.util.ArrayList;
2627
import java.util.Collections;
2728
import java.util.List;
@@ -498,6 +499,9 @@ protected HttpRequest getQueryMethod(QueryLanguage ql, String query, String base
498499
for (Map.Entry<String, String> additionalHeader : additionalHttpHeaders.entrySet()) {
499500
builder.header(additionalHeader.getKey(), additionalHeader.getValue());
500501
}
502+
if (maxQueryTime > 0) {
503+
builder.responseTimeout(Duration.ofSeconds(maxQueryTime));
504+
}
501505
return builder.build();
502506
}
503507

@@ -529,6 +533,9 @@ protected HttpRequest getUpdateMethod(QueryLanguage ql, String update, String ba
529533
builder.header(additionalHeader.getKey(), additionalHeader.getValue());
530534
}
531535
}
536+
if (maxQueryTime > 0) {
537+
builder.responseTimeout(Duration.ofSeconds(maxQueryTime));
538+
}
532539
return builder.build();
533540
}
534541

core/http/client/src/main/java/org/eclipse/rdf4j/http/client/query/AbstractHTTPQuery.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,10 @@ public Binding[] getBindingsArray() {
7070
@Override
7171
public void setMaxExecutionTime(int maxExecutionTimeSeconds) {
7272
super.setMaxExecutionTime(maxExecutionTimeSeconds);
73-
// TODO allow per query timeouts on the http connection used
74-
// Note: connection timeout is now configured via HttpClientConfig when building the client.
73+
// maxExecutionTimeSeconds is propagated as a per-request response timeout on the HTTP connection:
74+
// concrete subclasses pass getMaxExecutionTime() to SPARQLProtocolSession, which sets it on the
75+
// HttpRequest via HttpRequest.Builder#responseTimeout(Duration). The ApacheHC5RDF4JHttpClient
76+
// implementation applies it as a per-request RequestConfig#setResponseTimeout override.
7577
}
7678

7779
@Override

core/http/client/src/test/java/org/eclipse/rdf4j/http/client/SPARQLProtocolSessionTest.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,14 @@
2222
import java.io.IOException;
2323
import java.io.InputStream;
2424
import java.nio.charset.StandardCharsets;
25+
import java.time.Duration;
2526
import java.util.List;
2627
import java.util.concurrent.Executors;
2728
import java.util.stream.Stream;
2829

2930
import org.apache.commons.io.IOUtils;
3031
import org.eclipse.rdf4j.http.client.spi.HttpHeader;
32+
import org.eclipse.rdf4j.http.client.spi.HttpRequest;
3133
import org.eclipse.rdf4j.http.client.spi.HttpResponse;
3234
import org.eclipse.rdf4j.http.client.spi.RDF4JHttpClient;
3335
import org.eclipse.rdf4j.http.client.spi.RDF4JHttpClients;
@@ -299,6 +301,32 @@ public void testTupleQuery_Passthrough_ConfiguredFalse(String factoryName, MockS
299301
assertThat(out.toString()).startsWith("<");
300302
}
301303

304+
@ParameterizedTest(name = "[{0}]")
305+
@MethodSource("httpClientFactories")
306+
public void getQueryMethod_setsResponseTimeout_whenMaxQueryTimeIsPositive(String factoryName) {
307+
this.factoryName = factoryName;
308+
sparqlSession = createProtocolSession();
309+
310+
HttpRequest request = sparqlSession.getQueryMethod(
311+
QueryLanguage.SPARQL, "SELECT * WHERE { ?s ?p ?o }", null, null, true, 30);
312+
313+
assertThat(request.getResponseTimeout())
314+
.isPresent()
315+
.hasValue(Duration.ofSeconds(30));
316+
}
317+
318+
@ParameterizedTest(name = "[{0}]")
319+
@MethodSource("httpClientFactories")
320+
public void getQueryMethod_noResponseTimeout_whenMaxQueryTimeIsZero(String factoryName) {
321+
this.factoryName = factoryName;
322+
sparqlSession = createProtocolSession();
323+
324+
HttpRequest request = sparqlSession.getQueryMethod(
325+
QueryLanguage.SPARQL, "SELECT * WHERE { ?s ?p ?o }", null, null, true, 0);
326+
327+
assertThat(request.getResponseTimeout()).isEmpty();
328+
}
329+
302330
@Test
303331
public void getContentTypeSerialisationTest() {
304332
{

0 commit comments

Comments
 (0)