feat(http-client): extend HTTP client with facilities for more manual response processing
ci/woodpecker/push/woodpecker Pipeline was successful Details

This commit is contained in:
Johannes Frohnmeyer 2024-04-03 17:21:13 +02:00
parent 067f3b9de9
commit 7a0d2f726e
Signed by: Johannes
GPG Key ID: E76429612C2929F4
3 changed files with 159 additions and 25 deletions

View File

@ -1,5 +1,6 @@
package io.gitlab.jfronny.commons.http.client;
import io.gitlab.jfronny.commons.data.Either;
import io.gitlab.jfronny.commons.serialize.Serializer;
import java.io.*;
@ -60,6 +61,10 @@ public class HttpClient {
DELETE
}
private enum ResponseHandlingMode {
IGNORE_ALL, HANDLE_REDIRECTS, HANDLE_ALL
}
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;
@ -69,9 +74,10 @@ public class HttpClient {
private int retryAfterDefault = 5000;
private int retryAfterMax = 15000;
private int retryLimit = 3;
private ResponseHandlingMode responseHandlingMode = ResponseHandlingMode.HANDLE_ALL;
private List<Exception> retryExceptions = null;
public Request(Method method, String url) throws URISyntaxException {
private Request(Method method, String url) throws URISyntaxException {
this.url = url.replace(" ", "%20");
this.builder = HttpRequest.newBuilder()
.uri(new URI(this.url));
@ -95,6 +101,16 @@ public class HttpClient {
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");
@ -153,7 +169,7 @@ public class HttpClient {
return this;
}
private <T> T _send(String accept, HttpResponse.BodyHandler<T> responseBodyHandler) throws IOException {
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");
@ -175,7 +191,8 @@ public class HttpClient {
return handleRetryAfter(accept, responseBodyHandler, retryAfterDefault);
} else throw new IOException("Could not send request", e);
}
if (res.statusCode() / 100 == 2) return res.body();
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 {
@ -194,70 +211,96 @@ public class HttpClient {
// Redirect
if (location.isPresent() && method == Method.GET) {
try {
yield get(location.get())._send(accept, responseBodyHandler);
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 -> throw new FileNotFoundException("Didn't find anything under that url" + exceptionSuffix);
default -> throw new IOException("Unexpected return method: " + 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> T handleRetryAfter(String accept, HttpResponse.BodyHandler<T> responseBodyHandler, int millis) throws IOException {
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._send(accept, responseBodyHandler);
return this._sendResponse(accept, responseBodyHandler);
}
private <T> T unwrap(HttpResponse<T> response) throws IOException {
return response.body();
}
public void send() throws IOException {
_send("*/*", HttpResponse.BodyHandlers.discarding());
unwrap(sendResponse());
}
public HttpResponse<Void> sendResponse() throws IOException {
return _sendResponse("*/*", HttpResponse.BodyHandlers.discarding());
}
public InputStream sendInputStream() throws IOException {
return _send("*/*", HttpResponse.BodyHandlers.ofInputStream());
return unwrap(sendInputStreamResponse());
}
public HttpResponse<InputStream> sendInputStreamResponse() throws IOException {
return _sendResponse("*/*", HttpResponse.BodyHandlers.ofInputStream());
}
public Reader sendReader() throws IOException {
return new InputStreamReader(sendInputStream());
return unwrap(sendReaderResponse());
}
public HttpResponse<Reader> sendReaderResponse() throws IOException {
return _sendResponse("*/*", ReaderHandler.of());
}
public String sendString() throws IOException {
return _send("*/*", HttpResponse.BodyHandlers.ofString());
return unwrap(sendStringResponse());
}
public HttpResponse<String> sendStringResponse() throws IOException {
return _sendResponse("*/*", HttpResponse.BodyHandlers.ofString());
}
public Stream<String> sendLines() throws IOException {
return _send("*/*", HttpResponse.BodyHandlers.ofLines());
return unwrap(sendLinesResponse());
}
public HttpResponse<Stream<String>> sendLinesResponse() throws IOException {
return _sendResponse("*/*", HttpResponse.BodyHandlers.ofLines());
}
public <T> T sendSerialized(Type type) throws IOException {
Serializer serializer = Serializer.getInstance();
InputStream in = _send(serializer.getFormatMime(), HttpResponse.BodyHandlers.ofInputStream());
try {
return in == null ? null : serializer.deserialize(new InputStreamReader(in), type);
} catch (Serializer.SerializeException e) {
throw new IOException("Could not deserialize", e);
}
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());
}
private String getString(Object a) throws IOException {
if (a instanceof InputStream s) return new String(s.readAllBytes());
if (a instanceof String s) return s;
if (a instanceof Stream s) return ((Stream<String>) s).collect(Collectors.joining());
return "";
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,41 @@
package io.gitlab.jfronny.commons.http.client;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.http.HttpResponse;
import java.nio.ByteBuffer;
import java.util.List;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Flow;
public record ReaderHandler(HttpResponse.BodySubscriber<InputStream> delegate) implements HttpResponse.BodySubscriber<Reader> {
public static HttpResponse.BodyHandler<Reader> of() {
return responseInfo -> new ReaderHandler(HttpResponse.BodySubscribers.ofInputStream());
}
@Override
public CompletionStage<Reader> getBody() {
return this.delegate.getBody().thenApply(in -> in == null ? null : new InputStreamReader(in));
}
@Override
public void onSubscribe(Flow.Subscription subscription) {
this.delegate.onSubscribe(subscription);
}
@Override
public void onNext(List<ByteBuffer> item) {
this.delegate.onNext(item);
}
@Override
public void onError(Throwable throwable) {
this.delegate.onError(throwable);
}
@Override
public void onComplete() {
this.delegate.onComplete();
}
}

View File

@ -0,0 +1,50 @@
package io.gitlab.jfronny.commons.http.client;
import io.gitlab.jfronny.commons.data.Either;
import io.gitlab.jfronny.commons.serialize.Serializer;
import java.io.IOException;
import java.io.Reader;
import java.lang.reflect.Type;
import java.net.http.HttpResponse;
import java.nio.ByteBuffer;
import java.util.List;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Flow;
public record SerializedHandler<T>(HttpResponse.BodySubscriber<Reader> delegate, Serializer serializer, Type type) implements HttpResponse.BodySubscriber<Either<T, IOException>> {
public static <T> HttpResponse.BodyHandler<Either<T, IOException>> of(Serializer serializer, Type type) {
return responseInfo -> new SerializedHandler<>(new ReaderHandler(HttpResponse.BodySubscribers.ofInputStream()), serializer, type);
}
@Override
public CompletionStage<Either<T, IOException>> getBody() {
return this.delegate.getBody().thenApply(in -> {
try {
return Either.left(in == null ? null : this.serializer.<T>deserialize(in, this.type));
} catch (IOException e) {
return Either.right(e);
}
});
}
@Override
public void onSubscribe(Flow.Subscription subscription) {
this.delegate.onSubscribe(subscription);
}
@Override
public void onNext(List<ByteBuffer> byteBuffers) {
this.delegate.onNext(byteBuffers);
}
@Override
public void onError(Throwable throwable) {
this.delegate.onError(throwable);
}
@Override
public void onComplete() {
this.delegate.onComplete();
}
}