feat: break apart HttpClient

This commit is contained in:
Johannes Frohnmeyer 2024-04-03 17:28:49 +02:00
parent 86f100da8e
commit 01f1fdf188
Signed by: Johannes
GPG Key ID: E76429612C2929F4
4 changed files with 292 additions and 277 deletions

View File

@ -1,25 +1,12 @@
package io.gitlab.jfronny.commons.http.client;
import io.gitlab.jfronny.commons.data.Either;
import io.gitlab.jfronny.commons.serialize.Serializer;
import java.io.*;
import java.lang.reflect.Type;
import java.net.*;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class HttpClient {
private static String userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36";
private static final String PROXY_AUTH;
private static final java.net.http.HttpClient CLIENT;
protected static String userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36";
protected static final String PROXY_AUTH;
protected static final java.net.http.HttpClient CLIENT;
static {
// Enables HTTPS proxying
@ -53,274 +40,23 @@ public class HttpClient {
userAgent = hostname;
}
private enum Method {
GET,
POST,
PUT,
PATCH,
DELETE
public static RequestBuilder get(String url) throws URISyntaxException {
return new RequestBuilder(Method.GET, url);
}
private enum ResponseHandlingMode {
IGNORE_ALL, HANDLE_REDIRECTS, HANDLE_ALL
public static RequestBuilder post(String url) throws URISyntaxException {
return new RequestBuilder(Method.POST, url);
}
public static class Request {
private static final Predicate<String> CURSEFORGE_API = Pattern.compile("(?:http(s)?://)?addons-ecs\\.forgesvc\\.net/api/+").asMatchPredicate();
private final String url;
private final HttpRequest.Builder builder;
private Method method;
private int sent = 0;
private int retryAfterDefault = 5000;
private int retryAfterMax = 15000;
private int retryLimit = 3;
private ResponseHandlingMode responseHandlingMode = ResponseHandlingMode.HANDLE_ALL;
private List<Exception> retryExceptions = null;
private Request(Method method, String url) throws URISyntaxException {
this.url = url.replace(" ", "%20");
this.builder = HttpRequest.newBuilder()
.uri(new URI(this.url));
this.method = method;
userAgent(userAgent);
}
public Request bearer(String token) {
builder.header("Authorization", "Bearer " + token);
return this;
}
public Request header(String name, String value) {
builder.header(name, value);
return this;
}
public Request setHeader(String name, String value) {
builder.setHeader(name, value);
return this;
}
public Request ignoreAll() {
responseHandlingMode = ResponseHandlingMode.IGNORE_ALL;
return this;
}
public Request handleRedirects() {
responseHandlingMode = ResponseHandlingMode.HANDLE_REDIRECTS;
return this;
}
public Request configureRetryAfter(int defaultDelay, int maxDelay) {
if (defaultDelay < 1) throw new IllegalArgumentException("defaultDelay must be greater than zero");
if (maxDelay < defaultDelay) throw new IllegalArgumentException("maxDelay must be greater than or equal to defaultDelay");
retryAfterDefault = defaultDelay;
retryAfterMax = maxDelay;
return this;
}
public Request setRetryLimit(int limit) {
if (limit < 0) throw new IllegalArgumentException("limit must be greater than or zero");
retryLimit = limit;
return this;
}
public Request userAgent(String value) {
return setHeader("User-Agent", value);
}
public Request bodyString(String string) {
builder.header("Content-Type", "text/plain");
builder.method(method.name(), HttpRequest.BodyPublishers.ofString(string));
method = null;
return this;
}
public Request bodyForm(String string) {
builder.header("Content-Type", "application/x-www-form-urlencoded");
builder.method(method.name(), HttpRequest.BodyPublishers.ofString(string));
method = null;
return this;
}
public Request bodyForm(Map<String, String> entries) {
return bodyForm(entries.entrySet()
.stream()
.map(entry -> URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8) + '=' + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&")));
}
public Request bodyJson(String string) {
builder.header("Content-Type", "application/json");
builder.method(method.name(), HttpRequest.BodyPublishers.ofString(string));
method = null;
return this;
}
public Request bodySerialized(Object object) throws IOException {
Serializer serializer = Serializer.getInstance();
builder.header("Content-Type", serializer.getFormatMime());
builder.method(method.name(), HttpRequest.BodyPublishers.ofString(serializer.serialize(object)));
method = null;
return this;
}
private <T> HttpResponse<T> _sendResponse(String accept, HttpResponse.BodyHandler<T> responseBodyHandler) throws IOException {
sent++;
if (sent > retryLimit) {
IOException e = new IOException("Attempted to reconnect/redirect " + sent + " times, which is more than the permitted " + retryLimit + ". Stopping");
if (retryExceptions != null) for (Exception ex : retryExceptions) e.addSuppressed(ex);
throw e;
}
builder.header("Accept", accept);
if (method != null) builder.method(method.name(), HttpRequest.BodyPublishers.noBody());
if (PROXY_AUTH != null) builder.header("Proxy-Authorization", PROXY_AUTH);
HttpResponse<T> res;
try {
res = CLIENT.send(builder.build(), responseBodyHandler);
} catch (InterruptedException e) {
throw new IOException("Could not send request", e);
} catch (IOException e) {
String message = e.getMessage();
if (message != null && message.contains("GOAWAY received")) {
return handleRetryAfter(accept, responseBodyHandler, retryAfterDefault);
} else throw new IOException("Could not send request", e);
}
if (responseHandlingMode == ResponseHandlingMode.IGNORE_ALL) return res;
if (res.statusCode() / 100 == 2) return res;
Optional<String> location = res.headers().firstValue("location");
Optional<Integer> retryAfter = res.headers().firstValue("Retry-After").flatMap(s -> {
try {
return Optional.of(Integer.parseInt(s));
} catch (NumberFormatException e) {
return Optional.empty();
}
});
final String exceptionSuffix = " (URL=" + url + ")";
return switch (res.statusCode()) {
case 429 -> {
// Rate limit
yield handleRetryAfter(accept, responseBodyHandler, retryAfter.map(s -> s * 1000).orElse(retryAfterDefault));
}
case 302, 307 -> {
// Redirect
if (location.isPresent() && method == Method.GET) {
try {
yield get(location.get())._sendResponse(accept, responseBodyHandler);
} catch (URISyntaxException e) {
throw new IOException("Could not follow redirect" + exceptionSuffix, e);
}
}
if (responseHandlingMode == ResponseHandlingMode.HANDLE_REDIRECTS) yield res;
throw new IOException("Unexpected redirect: " + res.statusCode() + exceptionSuffix);
}
case 500, 502, 503, 504, 507 -> {
if (responseHandlingMode == ResponseHandlingMode.HANDLE_REDIRECTS) yield res;
// CurseForge serverside error
if (CURSEFORGE_API.test(url)) {
yield handleRetryAfter(accept, responseBodyHandler, Math.min(1000, retryAfterMax));
}
throw new IOException("Unexpected serverside error: " + res.statusCode() + exceptionSuffix);
}
case 404 -> {
if (responseHandlingMode == ResponseHandlingMode.HANDLE_REDIRECTS) yield res;
throw new FileNotFoundException("Didn't find anything under that url" + exceptionSuffix);
}
default -> {
if (responseHandlingMode == ResponseHandlingMode.HANDLE_REDIRECTS) yield res;
throw new IOException("Unexpected return method: " + res.statusCode() + exceptionSuffix);
}
};
}
private <T> HttpResponse<T> handleRetryAfter(String accept, HttpResponse.BodyHandler<T> responseBodyHandler, int millis) throws IOException {
if (millis > retryAfterMax) throw new HttpTimeoutException("Wait time specified by Retry-After is too long: " + millis);
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
throw new IOException("Could not sleep before resending request" + e);
}
return this._sendResponse(accept, responseBodyHandler);
}
private <T> T unwrap(HttpResponse<T> response) throws IOException {
return response.body();
}
public void send() throws IOException {
unwrap(sendResponse());
}
public HttpResponse<Void> sendResponse() throws IOException {
return _sendResponse("*/*", HttpResponse.BodyHandlers.discarding());
}
public InputStream sendInputStream() throws IOException {
return unwrap(sendInputStreamResponse());
}
public HttpResponse<InputStream> sendInputStreamResponse() throws IOException {
return _sendResponse("*/*", HttpResponse.BodyHandlers.ofInputStream());
}
public Reader sendReader() throws IOException {
return unwrap(sendReaderResponse());
}
public HttpResponse<Reader> sendReaderResponse() throws IOException {
return _sendResponse("*/*", ReaderHandler.of());
}
public String sendString() throws IOException {
return unwrap(sendStringResponse());
}
public HttpResponse<String> sendStringResponse() throws IOException {
return _sendResponse("*/*", HttpResponse.BodyHandlers.ofString());
}
public Stream<String> sendLines() throws IOException {
return unwrap(sendLinesResponse());
}
public HttpResponse<Stream<String>> sendLinesResponse() throws IOException {
return _sendResponse("*/*", HttpResponse.BodyHandlers.ofLines());
}
public <T> T sendSerialized(Type type) throws IOException {
Either<T, IOException> tmp = unwrap(sendSerializedResponse(type));
if (tmp == null) return null;
if (tmp.isLeft()) return tmp.left();
throw new IOException("Could not deserialize", tmp.right());
}
public <T> HttpResponse<Either<T, IOException>> sendSerializedResponse(Type type) throws IOException {
return _sendResponse(Serializer.getInstance().getFormatMime(), SerializedHandler.of(Serializer.getInstance(), type));
}
public static RequestBuilder put(String url) throws URISyntaxException {
return new RequestBuilder(Method.PUT, url);
}
public static Request get(String url) throws URISyntaxException {
return new Request(Method.GET, url);
public static RequestBuilder patch(String url) throws URISyntaxException {
return new RequestBuilder(Method.PATCH, url);
}
public static Request post(String url) throws URISyntaxException {
return new Request(Method.POST, url);
}
public static Request put(String url) throws URISyntaxException {
return new Request(Method.PUT, url);
}
public static Request patch(String url) throws URISyntaxException {
return new Request(Method.PATCH, url);
}
public static Request delete(String url) throws URISyntaxException {
return new Request(Method.DELETE, url);
public static RequestBuilder delete(String url) throws URISyntaxException {
return new RequestBuilder(Method.DELETE, url);
}
}

View File

@ -0,0 +1,9 @@
package io.gitlab.jfronny.commons.http.client;
enum Method {
GET,
POST,
PUT,
PATCH,
DELETE
}

View File

@ -0,0 +1,265 @@
package io.gitlab.jfronny.commons.http.client;
import io.gitlab.jfronny.commons.data.Either;
import io.gitlab.jfronny.commons.serialize.Serializer;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.lang.reflect.Type;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class RequestBuilder {
private static final Predicate<String> CURSEFORGE_API = Pattern.compile("(?:http(s)?://)?addons-ecs\\.forgesvc\\.net/api/+").asMatchPredicate();
private final String url;
private final HttpRequest.Builder builder;
private Method method;
private int sent = 0;
private int retryAfterDefault = 5000;
private int retryAfterMax = 15000;
private int retryLimit = 3;
private ResponseHandlingMode responseHandlingMode = ResponseHandlingMode.HANDLE_ALL;
private List<Exception> retryExceptions = null;
protected RequestBuilder(Method method, String url) throws URISyntaxException {
this.url = url.replace(" ", "%20");
this.builder = HttpRequest.newBuilder()
.uri(new URI(this.url));
this.method = method;
userAgent(HttpClient.userAgent);
}
public RequestBuilder bearer(String token) {
builder.header("Authorization", "Bearer " + token);
return this;
}
public RequestBuilder header(String name, String value) {
builder.header(name, value);
return this;
}
public RequestBuilder setHeader(String name, String value) {
builder.setHeader(name, value);
return this;
}
public RequestBuilder ignoreAll() {
responseHandlingMode = ResponseHandlingMode.IGNORE_ALL;
return this;
}
public RequestBuilder handleRedirects() {
responseHandlingMode = ResponseHandlingMode.HANDLE_REDIRECTS;
return this;
}
public RequestBuilder configureRetryAfter(int defaultDelay, int maxDelay) {
if (defaultDelay < 1) throw new IllegalArgumentException("defaultDelay must be greater than zero");
if (maxDelay < defaultDelay)
throw new IllegalArgumentException("maxDelay must be greater than or equal to defaultDelay");
retryAfterDefault = defaultDelay;
retryAfterMax = maxDelay;
return this;
}
public RequestBuilder setRetryLimit(int limit) {
if (limit < 0) throw new IllegalArgumentException("limit must be greater than or zero");
retryLimit = limit;
return this;
}
public RequestBuilder userAgent(String value) {
return setHeader("User-Agent", value);
}
public RequestBuilder bodyString(String string) {
builder.header("Content-Type", "text/plain");
builder.method(method.name(), HttpRequest.BodyPublishers.ofString(string));
method = null;
return this;
}
public RequestBuilder bodyForm(String string) {
builder.header("Content-Type", "application/x-www-form-urlencoded");
builder.method(method.name(), HttpRequest.BodyPublishers.ofString(string));
method = null;
return this;
}
public RequestBuilder bodyForm(Map<String, String> entries) {
return bodyForm(entries.entrySet()
.stream()
.map(entry -> URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8) + '=' + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&")));
}
public RequestBuilder bodyJson(String string) {
builder.header("Content-Type", "application/json");
builder.method(method.name(), HttpRequest.BodyPublishers.ofString(string));
method = null;
return this;
}
public RequestBuilder bodySerialized(Object object) throws IOException {
Serializer serializer = Serializer.getInstance();
builder.header("Content-Type", serializer.getFormatMime());
builder.method(method.name(), HttpRequest.BodyPublishers.ofString(serializer.serialize(object)));
method = null;
return this;
}
private <T> HttpResponse<T> _sendResponse(String accept, HttpResponse.BodyHandler<T> responseBodyHandler) throws IOException {
sent++;
if (sent > retryLimit) {
IOException e = new IOException("Attempted to reconnect/redirect " + sent + " times, which is more than the permitted " + retryLimit + ". Stopping");
if (retryExceptions != null) for (Exception ex : retryExceptions) e.addSuppressed(ex);
throw e;
}
builder.header("Accept", accept);
if (method != null) builder.method(method.name(), HttpRequest.BodyPublishers.noBody());
if (HttpClient.PROXY_AUTH != null) builder.header("Proxy-Authorization", HttpClient.PROXY_AUTH);
HttpResponse<T> res;
try {
res = HttpClient.CLIENT.send(builder.build(), responseBodyHandler);
} catch (InterruptedException e) {
throw new IOException("Could not send request", e);
} catch (IOException e) {
String message = e.getMessage();
if (message != null && message.contains("GOAWAY received")) {
return handleRetryAfter(accept, responseBodyHandler, retryAfterDefault);
} else throw new IOException("Could not send request", e);
}
if (responseHandlingMode == ResponseHandlingMode.IGNORE_ALL) return res;
if (res.statusCode() / 100 == 2) return res;
Optional<String> location = res.headers().firstValue("location");
Optional<Integer> retryAfter = res.headers().firstValue("Retry-After").flatMap(s -> {
try {
return Optional.of(Integer.parseInt(s));
} catch (NumberFormatException e) {
return Optional.empty();
}
});
final String exceptionSuffix = " (URL=" + url + ")";
return switch (res.statusCode()) {
case 429 -> {
// Rate limit
yield handleRetryAfter(accept, responseBodyHandler, retryAfter.map(s -> s * 1000).orElse(retryAfterDefault));
}
case 302, 307 -> {
// Redirect
if (location.isPresent() && method == Method.GET) {
try {
yield HttpClient.get(location.get())._sendResponse(accept, responseBodyHandler);
} catch (URISyntaxException e) {
throw new IOException("Could not follow redirect" + exceptionSuffix, e);
}
}
if (responseHandlingMode == ResponseHandlingMode.HANDLE_REDIRECTS) yield res;
throw new IOException("Unexpected redirect: " + res.statusCode() + exceptionSuffix);
}
case 500, 502, 503, 504, 507 -> {
if (responseHandlingMode == ResponseHandlingMode.HANDLE_REDIRECTS) yield res;
// CurseForge serverside error
if (CURSEFORGE_API.test(url)) {
yield handleRetryAfter(accept, responseBodyHandler, Math.min(1000, retryAfterMax));
}
throw new IOException("Unexpected serverside error: " + res.statusCode() + exceptionSuffix);
}
case 404 -> {
if (responseHandlingMode == ResponseHandlingMode.HANDLE_REDIRECTS) yield res;
throw new FileNotFoundException("Didn't find anything under that url" + exceptionSuffix);
}
default -> {
if (responseHandlingMode == ResponseHandlingMode.HANDLE_REDIRECTS) yield res;
throw new IOException("Unexpected return method: " + res.statusCode() + exceptionSuffix);
}
};
}
private <T> HttpResponse<T> handleRetryAfter(String accept, HttpResponse.BodyHandler<T> responseBodyHandler, int millis) throws IOException {
if (millis > retryAfterMax)
throw new HttpTimeoutException("Wait time specified by Retry-After is too long: " + millis);
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
throw new IOException("Could not sleep before resending request" + e);
}
return this._sendResponse(accept, responseBodyHandler);
}
private <T> T unwrap(HttpResponse<T> response) throws IOException {
return response.body();
}
public void send() throws IOException {
unwrap(sendResponse());
}
public HttpResponse<Void> sendResponse() throws IOException {
return _sendResponse("*/*", HttpResponse.BodyHandlers.discarding());
}
public InputStream sendInputStream() throws IOException {
return unwrap(sendInputStreamResponse());
}
public HttpResponse<InputStream> sendInputStreamResponse() throws IOException {
return _sendResponse("*/*", HttpResponse.BodyHandlers.ofInputStream());
}
public Reader sendReader() throws IOException {
return unwrap(sendReaderResponse());
}
public HttpResponse<Reader> sendReaderResponse() throws IOException {
return _sendResponse("*/*", ReaderHandler.of());
}
public String sendString() throws IOException {
return unwrap(sendStringResponse());
}
public HttpResponse<String> sendStringResponse() throws IOException {
return _sendResponse("*/*", HttpResponse.BodyHandlers.ofString());
}
public Stream<String> sendLines() throws IOException {
return unwrap(sendLinesResponse());
}
public HttpResponse<Stream<String>> sendLinesResponse() throws IOException {
return _sendResponse("*/*", HttpResponse.BodyHandlers.ofLines());
}
public <T> T sendSerialized(Type type) throws IOException {
Either<T, IOException> tmp = unwrap(sendSerializedResponse(type));
if (tmp == null) return null;
if (tmp.isLeft()) return tmp.left();
throw new IOException("Could not deserialize", tmp.right());
}
public <T> HttpResponse<Either<T, IOException>> sendSerializedResponse(Type type) throws IOException {
return _sendResponse(Serializer.getInstance().getFormatMime(), SerializedHandler.of(Serializer.getInstance(), type));
}
}

View File

@ -0,0 +1,5 @@
package io.gitlab.jfronny.commons.http.client;
enum ResponseHandlingMode {
IGNORE_ALL, HANDLE_REDIRECTS, HANDLE_ALL
}