HttpTransport.java
package net.sasasin.sreader.service.http;
import java.io.IOException;
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import net.sasasin.sreader.config.FeedReaderProperties;
import org.springframework.stereotype.Component;
/**
* Shared HTTP request construction, retry, and response decoding used by feed/article fetch and
* AutoPagerize page sessions. Does not own a long-lived {@link HttpClient}; callers supply one so
* cookie isolation can be scoped per article chain.
*/
@Component
public class HttpTransport {
private static final Pattern CHARSET_PATTERN =
Pattern.compile("charset=([^;]+)", Pattern.CASE_INSENSITIVE);
private final FeedReaderProperties properties;
public HttpTransport(FeedReaderProperties properties) {
this.properties = Objects.requireNonNull(properties, "properties must not be null");
}
/**
* Builds a cookie-isolated client that follows redirects with {@link HttpClient.Redirect#NORMAL}.
*/
public HttpClient newCookieIsolatedClient() {
CookieManager cookies = new CookieManager(null, CookiePolicy.ACCEPT_ALL);
return HttpClient.newBuilder()
.connectTimeout(properties.http().connectTimeout())
.followRedirects(HttpClient.Redirect.NORMAL)
.cookieHandler(cookies)
.build();
}
public HttpRequest.Builder baseRequest(URI uri) {
return HttpRequest.newBuilder(uri)
.header("User-Agent", properties.http().userAgent())
.header("Accept", "*/*");
}
public HttpRequest getRequest(URI uri) {
return baseRequest(uri).GET().timeout(properties.http().readTimeout()).build();
}
public HttpRequest headRequest(URI uri) {
return baseRequest(uri)
.method("HEAD", HttpRequest.BodyPublishers.noBody())
.timeout(Duration.ofSeconds(Math.max(1, properties.http().readTimeout().toSeconds())))
.build();
}
/**
* Sends a request with the configured retry count. Retries only {@link IOException};
* interruptions are not retried.
*/
public HttpResponse<byte[]> sendWithRetry(HttpClient client, HttpRequest request)
throws IOException, InterruptedException {
Objects.requireNonNull(client, "client must not be null");
Objects.requireNonNull(request, "request must not be null");
IOException last = null;
int attempts = Math.max(1, properties.http().retryCount() + 1);
for (int i = 0; i < attempts; i++) {
try {
return client.send(request, HttpResponse.BodyHandlers.ofByteArray());
} catch (IOException e) {
last = e;
}
}
throw last;
}
public HttpResponse<Void> sendDiscarding(HttpClient client, HttpRequest request)
throws IOException, InterruptedException {
Objects.requireNonNull(client, "client must not be null");
Objects.requireNonNull(request, "request must not be null");
return client.send(request, HttpResponse.BodyHandlers.discarding());
}
/**
* Performs GET with retry and requires a 2xx status. Returns final URI, decoded body, and raw
* response body length.
*/
public FetchedBytes get(HttpClient client, URI uri) throws IOException, InterruptedException {
HttpResponse<byte[]> response = sendWithRetry(client, getRequest(uri));
ensure2xx(uri, response.statusCode());
byte[] body = response.body() == null ? new byte[0] : response.body();
return new FetchedBytes(response.uri(), decode(response), body.length);
}
public void ensure2xx(URI requestedUri, int statusCode) throws HttpStatusException {
if (statusCode < 200 || statusCode >= 300) {
throw new HttpStatusException(requestedUri, statusCode);
}
}
public String decode(HttpResponse<byte[]> response) {
Charset charset =
response
.headers()
.firstValue("content-type")
.flatMap(this::charsetFromContentType)
.orElse(StandardCharsets.UTF_8);
byte[] body = response.body() == null ? new byte[0] : response.body();
return new String(body, charset);
}
private Optional<Charset> charsetFromContentType(String contentType) {
Matcher matcher = CHARSET_PATTERN.matcher(contentType);
if (!matcher.find()) {
return Optional.empty();
}
try {
return Optional.of(
Charset.forName(matcher.group(1).trim().replace("\"", "").toUpperCase(Locale.ROOT)));
} catch (IllegalArgumentException e) {
return Optional.empty();
}
}
public FeedReaderProperties.Http httpProperties() {
return properties.http();
}
/** Decoded GET response with raw byte length of the response body. */
public record FetchedBytes(URI finalUri, String body, long rawByteLength) {
public FetchedBytes {
Objects.requireNonNull(finalUri, "finalUri must not be null");
Objects.requireNonNull(body, "body must not be null");
if (rawByteLength < 0) {
throw new IllegalArgumentException("rawByteLength must not be negative");
}
}
}
}