Skip to content

Commit 634b7dc

Browse files
authored
GH-5723: Introduce pluggable HTTP client SPI with JDK and Apache HC5 backends (#5728)
2 parents 1026cfd + bd6fc4d commit 634b7dc

58 files changed

Lines changed: 3600 additions & 1313 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

compliance/repository/src/test/java/org/eclipse/rdf4j/repository/manager/RepositoryManagerIntegrationTest.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import java.net.URL;
1414
import java.util.Collection;
1515

16-
import org.apache.http.client.HttpClient;
16+
import org.eclipse.rdf4j.http.client.spi.RDF4JHttpClient;
1717
import org.eclipse.rdf4j.repository.Repository;
1818
import org.eclipse.rdf4j.repository.RepositoryException;
1919
import org.eclipse.rdf4j.repository.config.RepositoryConfig;
@@ -29,7 +29,7 @@ public void setUp() {
2929
subject = new RepositoryManager() {
3030

3131
@Override
32-
public void setHttpClient(HttpClient httpClient) {
32+
public void setHttpClient(RDF4JHttpClient httpClient) {
3333
// TODO Auto-generated method stub
3434

3535
}
@@ -40,7 +40,7 @@ public URL getLocation() {
4040
}
4141

4242
@Override
43-
public HttpClient getHttpClient() {
43+
public RDF4JHttpClient getHttpClient() {
4444
return null;
4545
}
4646

core/http/client-apache5/pom.xml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
3+
<modelVersion>4.0.0</modelVersion>
4+
<parent>
5+
<groupId>org.eclipse.rdf4j</groupId>
6+
<artifactId>rdf4j-http</artifactId>
7+
<version>6.0.0-SNAPSHOT</version>
8+
</parent>
9+
<artifactId>rdf4j-http-client-apache5</artifactId>
10+
<name>RDF4J: HTTP client Apache HttpComponents 5 implementation</name>
11+
<description>HTTP client implementation for RDF4J using Apache HttpComponents 5.</description>
12+
<dependencies>
13+
<dependency>
14+
<groupId>${project.groupId}</groupId>
15+
<artifactId>rdf4j-http-client-api</artifactId>
16+
<version>${project.version}</version>
17+
</dependency>
18+
<dependency>
19+
<groupId>org.apache.httpcomponents.client5</groupId>
20+
<artifactId>httpclient5</artifactId>
21+
</dependency>
22+
<dependency>
23+
<groupId>org.slf4j</groupId>
24+
<artifactId>slf4j-api</artifactId>
25+
</dependency>
26+
</dependencies>
27+
</project>
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2026 Eclipse RDF4J contributors.
3+
*
4+
* All rights reserved. This program and the accompanying materials
5+
* are made available under the terms of the Eclipse Distribution License v1.0
6+
* which accompanies this distribution, and is available at
7+
* http://www.eclipse.org/org/documents/edl-v10.php.
8+
*
9+
* SPDX-License-Identifier: BSD-3-Clause
10+
*******************************************************************************/
11+
package org.eclipse.rdf4j.http.client.apache5;
12+
13+
import java.io.IOException;
14+
import java.io.InputStream;
15+
import java.util.Arrays;
16+
import java.util.List;
17+
import java.util.stream.Collectors;
18+
19+
import org.apache.hc.core5.http.ClassicHttpResponse;
20+
import org.eclipse.rdf4j.http.client.spi.HttpHeader;
21+
import org.eclipse.rdf4j.http.client.spi.HttpResponse;
22+
23+
/**
24+
* {@link HttpResponse} backed by an Apache HC5 {@link ClassicHttpResponse}.
25+
* <p>
26+
* Closing this response returns the connection to the pool.
27+
*/
28+
public class ApacheHC5HttpClientResponse implements HttpResponse {
29+
30+
private final ClassicHttpResponse response;
31+
private final List<HttpHeader> headers;
32+
33+
public ApacheHC5HttpClientResponse(ClassicHttpResponse response) {
34+
this.response = response;
35+
this.headers = Arrays.stream(response.getHeaders())
36+
.map(h -> HttpHeader.of(h.getName(), h.getValue()))
37+
.collect(Collectors.toUnmodifiableList());
38+
}
39+
40+
@Override
41+
public int getStatusCode() {
42+
return response.getCode();
43+
}
44+
45+
@Override
46+
public String getReasonPhrase() {
47+
String reason = response.getReasonPhrase();
48+
return reason != null ? reason : "";
49+
}
50+
51+
@Override
52+
public List<HttpHeader> getHeaders() {
53+
return headers;
54+
}
55+
56+
@Override
57+
public InputStream getBodyAsStream() throws IOException {
58+
var entity = response.getEntity();
59+
if (entity == null) {
60+
return InputStream.nullInputStream();
61+
}
62+
return entity.getContent();
63+
}
64+
65+
@Override
66+
public void discard() throws IOException {
67+
var entity = response.getEntity();
68+
if (entity == null) {
69+
return;
70+
}
71+
if (entity.isStreaming()) {
72+
final InputStream inStream = entity.getContent();
73+
if (inStream != null) {
74+
inStream.close();
75+
}
76+
}
77+
}
78+
79+
@Override
80+
public void close() {
81+
try {
82+
response.close();
83+
} catch (IOException e) {
84+
// ignore on close
85+
}
86+
}
87+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2026 Eclipse RDF4J contributors.
3+
*
4+
* All rights reserved. This program and the accompanying materials
5+
* are made available under the terms of the Eclipse Distribution License v1.0
6+
* which accompanies this distribution, and is available at
7+
* http://www.eclipse.org/org/documents/edl-v10.php.
8+
*
9+
* SPDX-License-Identifier: BSD-3-Clause
10+
*******************************************************************************/
11+
package org.eclipse.rdf4j.http.client.apache5;
12+
13+
import java.io.IOException;
14+
import java.net.HttpURLConnection;
15+
16+
import org.apache.hc.client5.http.classic.methods.HttpDelete;
17+
import org.apache.hc.client5.http.classic.methods.HttpGet;
18+
import org.apache.hc.client5.http.classic.methods.HttpPost;
19+
import org.apache.hc.client5.http.classic.methods.HttpPut;
20+
import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase;
21+
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
22+
import org.apache.hc.core5.http.ClassicHttpResponse;
23+
import org.apache.hc.core5.http.ContentType;
24+
import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
25+
import org.apache.hc.core5.http.io.entity.InputStreamEntity;
26+
import org.eclipse.rdf4j.http.client.spi.HttpHeader;
27+
import org.eclipse.rdf4j.http.client.spi.HttpRequest;
28+
import org.eclipse.rdf4j.http.client.spi.HttpRequestBody;
29+
import org.eclipse.rdf4j.http.client.spi.HttpResponse;
30+
import org.eclipse.rdf4j.http.client.spi.RDF4JHttpClient;
31+
import org.slf4j.Logger;
32+
import org.slf4j.LoggerFactory;
33+
34+
/**
35+
* {@link RDF4JHttpClient} implementation backed by Apache HttpComponents 5.
36+
*/
37+
public class ApacheHC5RDF4JHttpClient implements RDF4JHttpClient {
38+
39+
private static final Logger logger = LoggerFactory.getLogger(ApacheHC5RDF4JHttpClient.class);
40+
41+
private final CloseableHttpClient httpClient;
42+
43+
/**
44+
* Maximum number of 408 retries (= maxConnectionsPerRoute + 1, to drain all stale pooled connections).
45+
*/
46+
private final int maxRetries408;
47+
48+
public ApacheHC5RDF4JHttpClient(CloseableHttpClient httpClient, int maxConnectionsPerRoute) {
49+
this.httpClient = httpClient;
50+
this.maxRetries408 = maxConnectionsPerRoute + 1;
51+
}
52+
53+
@Override
54+
public HttpResponse execute(HttpRequest request) throws IOException {
55+
boolean repeatable = request.getBody().map(HttpRequestBody::isRepeatable).orElse(true);
56+
57+
// Clear the interrupt flag before making the HTTP call so that Apache HC5's connection pool
58+
// (BasicFuture.get) does not immediately cancel the lease request when the calling thread
59+
// is in an interrupted state. The flag is restored in the finally block.
60+
boolean interrupted = Thread.interrupted();
61+
try {
62+
// Use executeOpen to get a streaming response (caller closes to return connection to pool).
63+
// Response-based retry (HTTP 408) is not applied automatically by executeOpen, so we implement
64+
// the retry loop here — but only for repeatable (byte-backed) bodies. Single-use stream bodies
65+
// cannot be re-sent, so retry is skipped and the 408 propagates to the caller.
66+
ClassicHttpResponse hcResponse = httpClient.executeOpen(null, buildRequest(request), null);
67+
if (!repeatable) {
68+
return new ApacheHC5HttpClientResponse(hcResponse);
69+
}
70+
for (int attempt = 1; hcResponse.getCode() == HttpURLConnection.HTTP_CLIENT_TIMEOUT
71+
&& attempt <= maxRetries408; attempt++) {
72+
try {
73+
hcResponse.close();
74+
} catch (Exception ignore) {
75+
}
76+
logger.info("Retrying request after HTTP 408 (attempt {})", attempt);
77+
hcResponse = httpClient.executeOpen(null, buildRequest(request), null);
78+
}
79+
return new ApacheHC5HttpClientResponse(hcResponse);
80+
} finally {
81+
if (interrupted) {
82+
Thread.currentThread().interrupt();
83+
}
84+
}
85+
}
86+
87+
private HttpUriRequestBase buildRequest(HttpRequest request) throws IOException {
88+
String method = request.getMethod();
89+
String uri = request.getUri().toASCIIString();
90+
91+
HttpUriRequestBase hcRequest = switch (method) {
92+
case "GET" -> new HttpGet(uri);
93+
case "POST" -> new HttpPost(uri);
94+
case "PUT" -> new HttpPut(uri);
95+
case "DELETE" -> new HttpDelete(uri);
96+
default -> new HttpUriRequestBase(method, request.getUri());
97+
};
98+
99+
// Set headers
100+
for (HttpHeader header : request.getHeaders()) {
101+
hcRequest.addHeader(header.getName(), header.getValue());
102+
}
103+
104+
// Set body. Repeatable (byte-backed) bodies use ByteArrayEntity so that Apache HC5's
105+
// RedirectExec can follow redirects and the 408 retry loop can re-send the request.
106+
// Single-use stream bodies use InputStreamEntity (non-repeatable); RedirectExec will
107+
// skip redirect following for those automatically.
108+
if (request.getBody().isPresent()) {
109+
HttpRequestBody body = request.getBody().get();
110+
ContentType contentType = ContentType.parse(body.getContentType());
111+
if (body.isRepeatable()) {
112+
hcRequest.setEntity(new ByteArrayEntity(body.getContent().readAllBytes(), contentType));
113+
} else {
114+
hcRequest.setEntity(new InputStreamEntity(body.getContent(), body.getContentLength(), contentType));
115+
}
116+
}
117+
118+
return hcRequest;
119+
}
120+
121+
@Override
122+
public void close() {
123+
try {
124+
httpClient.close();
125+
} catch (IOException e) {
126+
logger.trace("Error while closing http client: " + e.getMessage(), e);
127+
}
128+
}
129+
}

0 commit comments

Comments
 (0)