From 5e99ef7feac775df809e13377ce75843b4612b63 Mon Sep 17 00:00:00 2001 From: Rainnny7 Date: Sun, 28 Apr 2024 03:28:44 -0400 Subject: [PATCH] Add action queuing and rate limit handling --- .../braydon/pelican/action/PanelAction.java | 56 ++++++- .../braydon/pelican/action/PanelActions.java | 12 +- .../action/pelican/PelicanPanelActions.java | 7 +- .../action/pterodactyl/PteroPanelActions.java | 9 +- .../application/ApplicationNodeActions.java | 9 +- .../me/braydon/pelican/client/Pelican4J.java | 6 +- .../pelican/request/JsonWebRequest.java | 20 ++- ...bRequestHandler.java => QueuedAction.java} | 40 +++-- .../pelican/request/RateLimitHandler.java | 146 ++++++++++++++++++ .../pelican/test/PelicanActionTests.java | 12 +- 10 files changed, 269 insertions(+), 48 deletions(-) rename src/main/java/me/braydon/pelican/request/{WebRequestHandler.java => QueuedAction.java} (65%) create mode 100644 src/main/java/me/braydon/pelican/request/RateLimitHandler.java diff --git a/src/main/java/me/braydon/pelican/action/PanelAction.java b/src/main/java/me/braydon/pelican/action/PanelAction.java index f8b62a4..16ba632 100644 --- a/src/main/java/me/braydon/pelican/action/PanelAction.java +++ b/src/main/java/me/braydon/pelican/action/PanelAction.java @@ -25,10 +25,16 @@ package me.braydon.pelican.action; import lombok.AccessLevel; import lombok.AllArgsConstructor; +import lombok.Getter; import lombok.NonNull; +import me.braydon.pelican.client.ClientConfig; import me.braydon.pelican.model.PanelModel; import me.braydon.pelican.request.JsonWebRequest; -import me.braydon.pelican.request.WebRequestHandler; +import me.braydon.pelican.request.RateLimitHandler; + +import java.util.concurrent.CompletableFuture; +import java.util.function.BiConsumer; +import java.util.function.Consumer; /** * An action that can be executed on a panel. @@ -39,14 +45,19 @@ import me.braydon.pelican.request.WebRequestHandler; @AllArgsConstructor(access = AccessLevel.PRIVATE) public class PanelAction> { /** - * The web handler to execute actions with. + * The client config to use for executing actions. */ - @NonNull private final WebRequestHandler requestHandler; + @NonNull private final ClientConfig clientConfig; + + /** + * The rate limit handler to use for querying actions. + */ + @NonNull private final RateLimitHandler rateLimitHandler; /** * The web request this action will execute. */ - @NonNull private final JsonWebRequest webRequest; + @Getter @NonNull private final JsonWebRequest webRequest; /** * The type of response expected when @@ -54,27 +65,56 @@ public class PanelAction> { */ private final Class responseType; + /** + * Queue this action and await its response. + * The given callback will be invoked once + * a response has been received from the panel. + * + * @param callback the callback to invoke + */ + public void queue(@NonNull Consumer callback) { + queue((res, ex) -> { + if (ex != null) { + ex.printStackTrace(); + } + callback.accept(res); + }); + } + + /** + * Queue this action and await its response. + * The given callback will be invoked once + * a response has been received from the panel. + * + * @param callback the callback to invoke + */ + public void queue(@NonNull BiConsumer callback) { + CompletableFuture.runAsync(() -> rateLimitHandler.tryRequest(this, callback, false, true)); + } + /** * Execute this action instantly. * * @return the response, null if none */ public T execute() { - return requestHandler.handle(webRequest, responseType); + return webRequest.execute(clientConfig, responseType); } /** * Create a new panel action. * - * @param requestHandler the request handler to use + * @param clientConfig the client config to use for executing actions + * @param rateLimitHandler the rate limit handler to use for querying actions * @param webRequest the web request to send for this action * @param responseType the expected response type, null if none * @param the response type * @return the panel action */ @NonNull - public static > PanelAction create(@NonNull WebRequestHandler requestHandler, + public static > PanelAction create(@NonNull ClientConfig clientConfig, + @NonNull RateLimitHandler rateLimitHandler, @NonNull JsonWebRequest webRequest, Class responseType) { - return new PanelAction<>(requestHandler, webRequest, responseType); + return new PanelAction<>(clientConfig, rateLimitHandler, webRequest, responseType); } } \ No newline at end of file diff --git a/src/main/java/me/braydon/pelican/action/PanelActions.java b/src/main/java/me/braydon/pelican/action/PanelActions.java index 55748cd..5be4b2e 100644 --- a/src/main/java/me/braydon/pelican/action/PanelActions.java +++ b/src/main/java/me/braydon/pelican/action/PanelActions.java @@ -28,7 +28,8 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NonNull; import lombok.experimental.Accessors; -import me.braydon.pelican.request.WebRequestHandler; +import me.braydon.pelican.client.ClientConfig; +import me.braydon.pelican.request.RateLimitHandler; /** * A set of actions that can @@ -39,7 +40,12 @@ import me.braydon.pelican.request.WebRequestHandler; @AllArgsConstructor @Getter(AccessLevel.PROTECTED) @Accessors(fluent = true) public abstract class PanelActions { /** - * The request handler to use for action execution. + * The client config to use for executing actions. */ - @NonNull private final WebRequestHandler requestHandler; + @NonNull private final ClientConfig clientConfig; + + /** + * The rate limit handler to use for querying actions. + */ + @NonNull private final RateLimitHandler rateLimitHandler; } \ No newline at end of file diff --git a/src/main/java/me/braydon/pelican/action/pelican/PelicanPanelActions.java b/src/main/java/me/braydon/pelican/action/pelican/PelicanPanelActions.java index 2d9e918..d61711d 100644 --- a/src/main/java/me/braydon/pelican/action/pelican/PelicanPanelActions.java +++ b/src/main/java/me/braydon/pelican/action/pelican/PelicanPanelActions.java @@ -25,7 +25,8 @@ package me.braydon.pelican.action.pelican; import lombok.NonNull; import me.braydon.pelican.action.pterodactyl.PteroPanelActions; -import me.braydon.pelican.request.WebRequestHandler; +import me.braydon.pelican.client.ClientConfig; +import me.braydon.pelican.request.RateLimitHandler; /** * Implemented actions for the Pelican panel. @@ -34,7 +35,7 @@ import me.braydon.pelican.request.WebRequestHandler; * @see Pelican Website */ public class PelicanPanelActions extends PteroPanelActions { - public PelicanPanelActions(@NonNull WebRequestHandler requestHandler) { - super(requestHandler); + public PelicanPanelActions(@NonNull ClientConfig clientConfig, @NonNull RateLimitHandler rateLimitHandler) { + super(clientConfig, rateLimitHandler); } } \ No newline at end of file diff --git a/src/main/java/me/braydon/pelican/action/pterodactyl/PteroPanelActions.java b/src/main/java/me/braydon/pelican/action/pterodactyl/PteroPanelActions.java index 6e08816..f46c0d6 100644 --- a/src/main/java/me/braydon/pelican/action/pterodactyl/PteroPanelActions.java +++ b/src/main/java/me/braydon/pelican/action/pterodactyl/PteroPanelActions.java @@ -28,7 +28,8 @@ import lombok.NonNull; import lombok.experimental.Accessors; import me.braydon.pelican.action.PanelActions; import me.braydon.pelican.action.pterodactyl.application.ApplicationNodeActions; -import me.braydon.pelican.request.WebRequestHandler; +import me.braydon.pelican.client.ClientConfig; +import me.braydon.pelican.request.RateLimitHandler; /** * Implemented actions for the Pterodactyl panel. @@ -43,8 +44,8 @@ public class PteroPanelActions extends PanelActions { */ @NonNull private final Application application; - public PteroPanelActions(@NonNull WebRequestHandler requestHandler) { - super(requestHandler); + public PteroPanelActions(@NonNull ClientConfig clientConfig, @NonNull RateLimitHandler rateLimitHandler) { + super(clientConfig, rateLimitHandler); this.application = new Application(); } @@ -56,6 +57,6 @@ public class PteroPanelActions extends PanelActions { /** * Node actions for the application. */ - @NonNull private final ApplicationNodeActions nodes = new ApplicationNodeActions(requestHandler()); + @NonNull private final ApplicationNodeActions nodes = new ApplicationNodeActions(clientConfig(), rateLimitHandler()); } } \ No newline at end of file diff --git a/src/main/java/me/braydon/pelican/action/pterodactyl/application/ApplicationNodeActions.java b/src/main/java/me/braydon/pelican/action/pterodactyl/application/ApplicationNodeActions.java index ab4f189..1469f10 100644 --- a/src/main/java/me/braydon/pelican/action/pterodactyl/application/ApplicationNodeActions.java +++ b/src/main/java/me/braydon/pelican/action/pterodactyl/application/ApplicationNodeActions.java @@ -26,9 +26,10 @@ package me.braydon.pelican.action.pterodactyl.application; import lombok.NonNull; import me.braydon.pelican.action.PanelAction; import me.braydon.pelican.action.PanelActions; +import me.braydon.pelican.client.ClientConfig; import me.braydon.pelican.model.Node; import me.braydon.pelican.request.JsonWebRequest; -import me.braydon.pelican.request.WebRequestHandler; +import me.braydon.pelican.request.RateLimitHandler; /** * Application node actions @@ -37,8 +38,8 @@ import me.braydon.pelican.request.WebRequestHandler; * @author Braydon */ public final class ApplicationNodeActions extends PanelActions { - public ApplicationNodeActions(@NonNull WebRequestHandler requestHandler) { - super(requestHandler); + public ApplicationNodeActions(@NonNull ClientConfig clientConfig, @NonNull RateLimitHandler rateLimitHandler) { + super(clientConfig, rateLimitHandler); } /** @@ -49,7 +50,7 @@ public final class ApplicationNodeActions extends PanelActions { * @return the action */ public PanelAction details(int id) { - return PanelAction.create(requestHandler(), JsonWebRequest.builder() + return PanelAction.create(clientConfig(), rateLimitHandler(), JsonWebRequest.builder() .endpoint("/application/nodes/" + id) .build(), Node.class); } diff --git a/src/main/java/me/braydon/pelican/client/Pelican4J.java b/src/main/java/me/braydon/pelican/client/Pelican4J.java index 109071c..235e21c 100644 --- a/src/main/java/me/braydon/pelican/client/Pelican4J.java +++ b/src/main/java/me/braydon/pelican/client/Pelican4J.java @@ -31,7 +31,7 @@ import lombok.extern.slf4j.Slf4j; import me.braydon.pelican.action.PanelActions; import me.braydon.pelican.action.pelican.PelicanPanelActions; import me.braydon.pelican.action.pterodactyl.PteroPanelActions; -import me.braydon.pelican.request.WebRequestHandler; +import me.braydon.pelican.request.RateLimitHandler; import java.io.Closeable; @@ -57,7 +57,9 @@ public final class Pelican4J implements Closeable { @SneakyThrows private Pelican4J(@NonNull ClientConfig config, @NonNull Class actionsClass) { this.config = config; - actions = actionsClass.getConstructor(WebRequestHandler.class).newInstance(new WebRequestHandler(config)); + actions = actionsClass.getConstructor(ClientConfig.class, RateLimitHandler.class).newInstance( + config, new RateLimitHandler(config) + ); if (config.debugging()) { log.debug("Created a new {} client: {}", actionsClass == PelicanPanelActions.class ? "Pelican" : "Ptero", config); } diff --git a/src/main/java/me/braydon/pelican/request/JsonWebRequest.java b/src/main/java/me/braydon/pelican/request/JsonWebRequest.java index 2b0c3ad..1e1d39a 100644 --- a/src/main/java/me/braydon/pelican/request/JsonWebRequest.java +++ b/src/main/java/me/braydon/pelican/request/JsonWebRequest.java @@ -29,6 +29,8 @@ import com.google.gson.JsonObject; import lombok.Builder; import lombok.NonNull; import lombok.SneakyThrows; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; import me.braydon.pelican.client.ClientConfig; import me.braydon.pelican.exception.PanelAPIException; import me.braydon.pelican.model.PanelModel; @@ -39,7 +41,8 @@ import okhttp3.*; * * @author Braydon */ -@Builder +@Builder @ToString +@Slf4j(topic = "Web Request") public class JsonWebRequest { private static final OkHttpClient HTTP_CLIENT = new OkHttpClient(); private static final MediaType JSON_MEDIA = MediaType.get("application/json"); @@ -71,10 +74,17 @@ public class JsonWebRequest { * @param the response type */ @SneakyThrows - protected > T execute(@NonNull ClientConfig clientConfig, Class responseType) { + public > T execute(@NonNull ClientConfig clientConfig, Class responseType) { + String endpoint = clientConfig.panelUrl() + "/api" + this.endpoint; + if (clientConfig.debugging()) { + log.debug("Sending a {} request to {}...", method, endpoint); + if (body != null) { + log.debug("With Body: {}", body); + } + } Request request = new Request.Builder() .method(method.name(), body == null ? null : RequestBody.create(body, JSON_MEDIA)) - .url(clientConfig.panelUrl() + "/api" + endpoint) + .url(endpoint) .addHeader("Content-Type", "application/json") .addHeader("Accept", "Application/vnd.pterodactyl.v1+json") .addHeader("Authorization", "Bearer " + clientConfig.apiKey()) @@ -85,6 +95,10 @@ public class JsonWebRequest { int status = response.code(); // The HTTP response code String json = response.body().string(); // The json response + if (clientConfig.debugging()) { + log.debug("Receive response: {}", status); + } + // If the status is not 200 (OK), handle the error if (status != 200) { JsonObject errorJsonObject = GSON.fromJson(json, JsonObject.class); diff --git a/src/main/java/me/braydon/pelican/request/WebRequestHandler.java b/src/main/java/me/braydon/pelican/request/QueuedAction.java similarity index 65% rename from src/main/java/me/braydon/pelican/request/WebRequestHandler.java rename to src/main/java/me/braydon/pelican/request/QueuedAction.java index 22322bc..3adc65d 100644 --- a/src/main/java/me/braydon/pelican/request/WebRequestHandler.java +++ b/src/main/java/me/braydon/pelican/request/QueuedAction.java @@ -23,33 +23,43 @@ */ package me.braydon.pelican.request; +import lombok.AccessLevel; import lombok.AllArgsConstructor; +import lombok.Getter; import lombok.NonNull; -import me.braydon.pelican.client.ClientConfig; +import me.braydon.pelican.action.PanelAction; import me.braydon.pelican.model.PanelModel; +import java.util.function.BiConsumer; + /** - * The handler for processing web requests. + * Represents a queued action. * * @author Braydon */ -@AllArgsConstructor -public final class WebRequestHandler { +@AllArgsConstructor(access = AccessLevel.PROTECTED) @Getter +public class QueuedAction> { /** - * The client config used to make requests. + * The action that's queued. */ - @NonNull private final ClientConfig clientConfig; + @NonNull private final PanelAction action; /** - * Handle the given web request. - * - * @param request the request to handle - * @param responseType the expected response type, null if none - * @return the response, null if none - * @param the response type + * The callback to invoke when + * this action is executed. */ - public > T handle(@NonNull JsonWebRequest request, Class responseType) { - // TODO: handle rate limit handling for async reqs - return request.execute(clientConfig, responseType); + @NonNull private final BiConsumer callback; + + /** + * The amount of times this + * action has been retried. + */ + private int retries; + + /** + * Increment the retry count. + */ + protected void incrementRetries() { + retries++; } } \ No newline at end of file diff --git a/src/main/java/me/braydon/pelican/request/RateLimitHandler.java b/src/main/java/me/braydon/pelican/request/RateLimitHandler.java new file mode 100644 index 0000000..c1984ba --- /dev/null +++ b/src/main/java/me/braydon/pelican/request/RateLimitHandler.java @@ -0,0 +1,146 @@ +/* + * MIT License + * + * Copyright (c) 2024 Braydon (Rainnny). + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package me.braydon.pelican.request; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import me.braydon.pelican.action.PanelAction; +import me.braydon.pelican.client.ClientConfig; +import me.braydon.pelican.exception.PanelAPIException; +import me.braydon.pelican.model.PanelModel; + +import java.util.LinkedList; +import java.util.List; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; + +/** + * Responsible for handling panel rate limits. + * + * @author Braydon + */ +@Slf4j(topic = "Rate Limit Handler") +public final class RateLimitHandler { + /** + * The interval at which requests should be retried at if they are rate limited. + */ + private static final long INTERVAL = TimeUnit.SECONDS.toMillis(2L); + + /** + * The amount of times a queued action should + * be retried before failing the action. + */ + private static final int MAX_RETRIES = 25; + + /** + * The client config to use. + */ + @NonNull private final ClientConfig clientConfig; + + /** + * The currently queued actions, awaiting to be re-tried. + */ + private final List> queuedActions = new LinkedList<>(); + + public RateLimitHandler(@NonNull ClientConfig clientConfig) { + this.clientConfig = clientConfig; + + // Schedule a task to process the queue. + // This will take the first element in the queue + // every X interval, and try to execute it. Each + // time the action fails, the retry count will be + // incremented. If the action is successful, or reaches + // the max retries, it will be removed from the queue. + new Timer().scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + int queueSize = queuedActions.size(); // The size of the queue + if (queueSize == 0) { // Queue is empty + return; + } + if (clientConfig.debugging()) { + log.debug("Processing queue of size {}...", queueSize); + } + QueuedAction queuedAction = queuedActions.get(0); // Get the first queued action + boolean maxedRetries = queuedAction.getRetries() >= MAX_RETRIES; // If the action has maxed retries + + // Re-try the queued action, and if it fails, increment the retry count and move on + if (!tryRequest(queuedAction.getAction(), queuedAction.getCallback(), maxedRetries, false)) { + queuedAction.incrementRetries(); + if (!maxedRetries) { + return; + } + } + // The queued action was either successful, or + // reached the max retries, remove it from the queue + queuedActions.remove(0); + } + }, INTERVAL, INTERVAL); + } + + /** + * Try and execute the given action. + * + * @param action the action to try + * @param callback the callback to invoke + * @param throwRateLimitErrors whether rate limit errors should be thrown + * @param retry should the action be retried if a rate limit is hit (and being thrown)? + * @return whether the request was successful + * @param the action response type + */ + @SuppressWarnings("unchecked") + public > boolean tryRequest(@NonNull PanelAction action, BiConsumer callback, boolean throwRateLimitErrors, boolean retry) { + try { // Try and execute the request + if (clientConfig.debugging()) { + log.debug("Trying to execute action {}...", action.getWebRequest()); + } + callback.accept((T) action.execute(), null); + return true; // Success + } catch (Exception ex) { + // The API rate limit has been reached, queue + // the task to be re-tried later + if (ex instanceof PanelAPIException) { + PanelAPIException apiException = (PanelAPIException) ex; + if (apiException.getCode() == 404 && !throwRateLimitErrors) { + if (clientConfig.debugging()) { + log.debug("Panel API rate limit exceeded{}", retry ? ", queued action to be re-tried later..." : ""); + } + if (retry) { + queuedActions.add(new QueuedAction<>(action, callback, 0)); + } + return false; + } + } + // Not a rate limit, invoke + // the callback with the error + if (clientConfig.debugging()) { + log.debug("Failed to execute action:", ex); + } + callback.accept(null, ex); + return false; + } + } +} \ No newline at end of file diff --git a/src/test/java/me/braydon/pelican/test/PelicanActionTests.java b/src/test/java/me/braydon/pelican/test/PelicanActionTests.java index 84cc95b..0619004 100644 --- a/src/test/java/me/braydon/pelican/test/PelicanActionTests.java +++ b/src/test/java/me/braydon/pelican/test/PelicanActionTests.java @@ -23,11 +23,10 @@ */ package me.braydon.pelican.test; +import lombok.SneakyThrows; import me.braydon.pelican.action.pelican.PelicanPanelActions; import me.braydon.pelican.client.ClientConfig; import me.braydon.pelican.client.Pelican4J; -import me.braydon.pelican.exception.PanelAPIException; -import me.braydon.pelican.model.Node; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -65,11 +64,12 @@ public final class PelicanActionTests { * Test getting a list of * nodes from the panel. */ - @Test + @Test @SneakyThrows void testGetNodes() { - Node node = client.actions().application().nodes().details(-1).execute(); - System.out.println("node = " + node); - // TODO: ... + client.actions().application().nodes().details(1).queue(node -> { + System.out.println("node = " + node); + }); + Thread.sleep(60000L); } } } \ No newline at end of file