Add action queuing and rate limit handling

This commit is contained in:
Braydon 2024-04-28 03:28:44 -04:00
parent 93fde2621d
commit 5e99ef7fea
10 changed files with 269 additions and 48 deletions

@ -25,10 +25,16 @@ package me.braydon.pelican.action;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NonNull; import lombok.NonNull;
import me.braydon.pelican.client.ClientConfig;
import me.braydon.pelican.model.PanelModel; import me.braydon.pelican.model.PanelModel;
import me.braydon.pelican.request.JsonWebRequest; 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. * An action that can be executed on a panel.
@ -39,14 +45,19 @@ import me.braydon.pelican.request.WebRequestHandler;
@AllArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE)
public class PanelAction<T extends PanelModel<T>> { public class PanelAction<T extends PanelModel<T>> {
/** /**
* 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. * The web request this action will execute.
*/ */
@NonNull private final JsonWebRequest webRequest; @Getter @NonNull private final JsonWebRequest webRequest;
/** /**
* The type of response expected when * The type of response expected when
@ -54,27 +65,56 @@ public class PanelAction<T extends PanelModel<T>> {
*/ */
private final Class<T> responseType; private final Class<T> 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<T> 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<T, Exception> callback) {
CompletableFuture.runAsync(() -> rateLimitHandler.tryRequest(this, callback, false, true));
}
/** /**
* Execute this action instantly. * Execute this action instantly.
* *
* @return the response, null if none * @return the response, null if none
*/ */
public T execute() { public T execute() {
return requestHandler.handle(webRequest, responseType); return webRequest.execute(clientConfig, responseType);
} }
/** /**
* Create a new panel action. * 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 webRequest the web request to send for this action
* @param responseType the expected response type, null if none * @param responseType the expected response type, null if none
* @param <T> the response type * @param <T> the response type
* @return the panel action * @return the panel action
*/ */
@NonNull @NonNull
public static <T extends PanelModel<T>> PanelAction<T> create(@NonNull WebRequestHandler requestHandler, public static <T extends PanelModel<T>> PanelAction<T> create(@NonNull ClientConfig clientConfig,
@NonNull RateLimitHandler rateLimitHandler,
@NonNull JsonWebRequest webRequest, Class<T> responseType) { @NonNull JsonWebRequest webRequest, Class<T> responseType) {
return new PanelAction<>(requestHandler, webRequest, responseType); return new PanelAction<>(clientConfig, rateLimitHandler, webRequest, responseType);
} }
} }

@ -28,7 +28,8 @@ import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
import lombok.NonNull; import lombok.NonNull;
import lombok.experimental.Accessors; 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 * A set of actions that can
@ -39,7 +40,12 @@ import me.braydon.pelican.request.WebRequestHandler;
@AllArgsConstructor @Getter(AccessLevel.PROTECTED) @Accessors(fluent = true) @AllArgsConstructor @Getter(AccessLevel.PROTECTED) @Accessors(fluent = true)
public abstract class PanelActions { 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;
} }

@ -25,7 +25,8 @@ package me.braydon.pelican.action.pelican;
import lombok.NonNull; import lombok.NonNull;
import me.braydon.pelican.action.pterodactyl.PteroPanelActions; 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. * Implemented actions for the Pelican panel.
@ -34,7 +35,7 @@ import me.braydon.pelican.request.WebRequestHandler;
* @see <a href="https://pelican.dev">Pelican Website</a> * @see <a href="https://pelican.dev">Pelican Website</a>
*/ */
public class PelicanPanelActions extends PteroPanelActions { public class PelicanPanelActions extends PteroPanelActions {
public PelicanPanelActions(@NonNull WebRequestHandler requestHandler) { public PelicanPanelActions(@NonNull ClientConfig clientConfig, @NonNull RateLimitHandler rateLimitHandler) {
super(requestHandler); super(clientConfig, rateLimitHandler);
} }
} }

@ -28,7 +28,8 @@ import lombok.NonNull;
import lombok.experimental.Accessors; import lombok.experimental.Accessors;
import me.braydon.pelican.action.PanelActions; import me.braydon.pelican.action.PanelActions;
import me.braydon.pelican.action.pterodactyl.application.ApplicationNodeActions; 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. * Implemented actions for the Pterodactyl panel.
@ -43,8 +44,8 @@ public class PteroPanelActions extends PanelActions {
*/ */
@NonNull private final Application application; @NonNull private final Application application;
public PteroPanelActions(@NonNull WebRequestHandler requestHandler) { public PteroPanelActions(@NonNull ClientConfig clientConfig, @NonNull RateLimitHandler rateLimitHandler) {
super(requestHandler); super(clientConfig, rateLimitHandler);
this.application = new Application(); this.application = new Application();
} }
@ -56,6 +57,6 @@ public class PteroPanelActions extends PanelActions {
/** /**
* Node actions for the application. * Node actions for the application.
*/ */
@NonNull private final ApplicationNodeActions nodes = new ApplicationNodeActions(requestHandler()); @NonNull private final ApplicationNodeActions nodes = new ApplicationNodeActions(clientConfig(), rateLimitHandler());
} }
} }

@ -26,9 +26,10 @@ package me.braydon.pelican.action.pterodactyl.application;
import lombok.NonNull; import lombok.NonNull;
import me.braydon.pelican.action.PanelAction; import me.braydon.pelican.action.PanelAction;
import me.braydon.pelican.action.PanelActions; import me.braydon.pelican.action.PanelActions;
import me.braydon.pelican.client.ClientConfig;
import me.braydon.pelican.model.Node; import me.braydon.pelican.model.Node;
import me.braydon.pelican.request.JsonWebRequest; import me.braydon.pelican.request.JsonWebRequest;
import me.braydon.pelican.request.WebRequestHandler; import me.braydon.pelican.request.RateLimitHandler;
/** /**
* Application node actions * Application node actions
@ -37,8 +38,8 @@ import me.braydon.pelican.request.WebRequestHandler;
* @author Braydon * @author Braydon
*/ */
public final class ApplicationNodeActions extends PanelActions { public final class ApplicationNodeActions extends PanelActions {
public ApplicationNodeActions(@NonNull WebRequestHandler requestHandler) { public ApplicationNodeActions(@NonNull ClientConfig clientConfig, @NonNull RateLimitHandler rateLimitHandler) {
super(requestHandler); super(clientConfig, rateLimitHandler);
} }
/** /**
@ -49,7 +50,7 @@ public final class ApplicationNodeActions extends PanelActions {
* @return the action * @return the action
*/ */
public PanelAction<Node> details(int id) { public PanelAction<Node> details(int id) {
return PanelAction.create(requestHandler(), JsonWebRequest.builder() return PanelAction.create(clientConfig(), rateLimitHandler(), JsonWebRequest.builder()
.endpoint("/application/nodes/" + id) .endpoint("/application/nodes/" + id)
.build(), Node.class); .build(), Node.class);
} }

@ -31,7 +31,7 @@ import lombok.extern.slf4j.Slf4j;
import me.braydon.pelican.action.PanelActions; import me.braydon.pelican.action.PanelActions;
import me.braydon.pelican.action.pelican.PelicanPanelActions; import me.braydon.pelican.action.pelican.PelicanPanelActions;
import me.braydon.pelican.action.pterodactyl.PteroPanelActions; import me.braydon.pelican.action.pterodactyl.PteroPanelActions;
import me.braydon.pelican.request.WebRequestHandler; import me.braydon.pelican.request.RateLimitHandler;
import java.io.Closeable; import java.io.Closeable;
@ -57,7 +57,9 @@ public final class Pelican4J<A extends PanelActions> implements Closeable {
@SneakyThrows @SneakyThrows
private Pelican4J(@NonNull ClientConfig config, @NonNull Class<A> actionsClass) { private Pelican4J(@NonNull ClientConfig config, @NonNull Class<A> actionsClass) {
this.config = config; 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()) { if (config.debugging()) {
log.debug("Created a new {} client: {}", actionsClass == PelicanPanelActions.class ? "Pelican" : "Ptero", config); log.debug("Created a new {} client: {}", actionsClass == PelicanPanelActions.class ? "Pelican" : "Ptero", config);
} }

@ -29,6 +29,8 @@ import com.google.gson.JsonObject;
import lombok.Builder; import lombok.Builder;
import lombok.NonNull; import lombok.NonNull;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import me.braydon.pelican.client.ClientConfig; import me.braydon.pelican.client.ClientConfig;
import me.braydon.pelican.exception.PanelAPIException; import me.braydon.pelican.exception.PanelAPIException;
import me.braydon.pelican.model.PanelModel; import me.braydon.pelican.model.PanelModel;
@ -39,7 +41,8 @@ import okhttp3.*;
* *
* @author Braydon * @author Braydon
*/ */
@Builder @Builder @ToString
@Slf4j(topic = "Web Request")
public class JsonWebRequest { public class JsonWebRequest {
private static final OkHttpClient HTTP_CLIENT = new OkHttpClient(); private static final OkHttpClient HTTP_CLIENT = new OkHttpClient();
private static final MediaType JSON_MEDIA = MediaType.get("application/json"); private static final MediaType JSON_MEDIA = MediaType.get("application/json");
@ -71,10 +74,17 @@ public class JsonWebRequest {
* @param <T> the response type * @param <T> the response type
*/ */
@SneakyThrows @SneakyThrows
protected <T extends PanelModel<T>> T execute(@NonNull ClientConfig clientConfig, Class<T> responseType) { public <T extends PanelModel<T>> T execute(@NonNull ClientConfig clientConfig, Class<T> 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() Request request = new Request.Builder()
.method(method.name(), body == null ? null : RequestBody.create(body, JSON_MEDIA)) .method(method.name(), body == null ? null : RequestBody.create(body, JSON_MEDIA))
.url(clientConfig.panelUrl() + "/api" + endpoint) .url(endpoint)
.addHeader("Content-Type", "application/json") .addHeader("Content-Type", "application/json")
.addHeader("Accept", "Application/vnd.pterodactyl.v1+json") .addHeader("Accept", "Application/vnd.pterodactyl.v1+json")
.addHeader("Authorization", "Bearer " + clientConfig.apiKey()) .addHeader("Authorization", "Bearer " + clientConfig.apiKey())
@ -85,6 +95,10 @@ public class JsonWebRequest {
int status = response.code(); // The HTTP response code int status = response.code(); // The HTTP response code
String json = response.body().string(); // The json response 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 the status is not 200 (OK), handle the error
if (status != 200) { if (status != 200) {
JsonObject errorJsonObject = GSON.fromJson(json, JsonObject.class); JsonObject errorJsonObject = GSON.fromJson(json, JsonObject.class);

@ -23,33 +23,43 @@
*/ */
package me.braydon.pelican.request; package me.braydon.pelican.request;
import lombok.AccessLevel;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NonNull; import lombok.NonNull;
import me.braydon.pelican.client.ClientConfig; import me.braydon.pelican.action.PanelAction;
import me.braydon.pelican.model.PanelModel; import me.braydon.pelican.model.PanelModel;
import java.util.function.BiConsumer;
/** /**
* The handler for processing web requests. * Represents a queued action.
* *
* @author Braydon * @author Braydon
*/ */
@AllArgsConstructor @AllArgsConstructor(access = AccessLevel.PROTECTED) @Getter
public final class WebRequestHandler { public class QueuedAction<T extends PanelModel<T>> {
/** /**
* 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. * The callback to invoke when
* * this action is executed.
* @param request the request to handle
* @param responseType the expected response type, null if none
* @return the response, null if none
* @param <T> the response type
*/ */
public <T extends PanelModel<T>> T handle(@NonNull JsonWebRequest request, Class<T> responseType) { @NonNull private final BiConsumer<T, Exception> callback;
// TODO: handle rate limit handling for async reqs
return request.execute(clientConfig, responseType); /**
* The amount of times this
* action has been retried.
*/
private int retries;
/**
* Increment the retry count.
*/
protected void incrementRetries() {
retries++;
} }
} }

@ -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<QueuedAction<?>> 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 <T> the action response type
*/
@SuppressWarnings("unchecked")
public <T extends PanelModel<T>> boolean tryRequest(@NonNull PanelAction<?> action, BiConsumer<T, Exception> 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;
}
}
}

@ -23,11 +23,10 @@
*/ */
package me.braydon.pelican.test; package me.braydon.pelican.test;
import lombok.SneakyThrows;
import me.braydon.pelican.action.pelican.PelicanPanelActions; import me.braydon.pelican.action.pelican.PelicanPanelActions;
import me.braydon.pelican.client.ClientConfig; import me.braydon.pelican.client.ClientConfig;
import me.braydon.pelican.client.Pelican4J; 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.BeforeAll;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -65,11 +64,12 @@ public final class PelicanActionTests {
* Test getting a list of * Test getting a list of
* nodes from the panel. * nodes from the panel.
*/ */
@Test @Test @SneakyThrows
void testGetNodes() { void testGetNodes() {
Node node = client.actions().application().nodes().details(-1).execute(); client.actions().application().nodes().details(1).queue(node -> {
System.out.println("node = " + node); System.out.println("node = " + node);
// TODO: ... });
Thread.sleep(60000L);
} }
} }
} }