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.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<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.
*/
@NonNull private final JsonWebRequest webRequest;
@Getter @NonNull private final JsonWebRequest webRequest;
/**
* The type of response expected when
@ -54,27 +65,56 @@ public class PanelAction<T extends PanelModel<T>> {
*/
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.
*
* @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 <T> the response type
* @return the panel action
*/
@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) {
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.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;
}

@ -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 <a href="https://pelican.dev">Pelican Website</a>
*/
public class PelicanPanelActions extends PteroPanelActions {
public PelicanPanelActions(@NonNull WebRequestHandler requestHandler) {
super(requestHandler);
public PelicanPanelActions(@NonNull ClientConfig clientConfig, @NonNull RateLimitHandler rateLimitHandler) {
super(clientConfig, rateLimitHandler);
}
}

@ -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());
}
}

@ -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<Node> details(int id) {
return PanelAction.create(requestHandler(), JsonWebRequest.builder()
return PanelAction.create(clientConfig(), rateLimitHandler(), JsonWebRequest.builder()
.endpoint("/application/nodes/" + id)
.build(), Node.class);
}

@ -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<A extends PanelActions> implements Closeable {
@SneakyThrows
private Pelican4J(@NonNull ClientConfig config, @NonNull Class<A> 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);
}

@ -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 <T> the response type
*/
@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()
.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);

@ -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<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.
*
* @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
* The callback to invoke when
* this action is executed.
*/
public <T extends PanelModel<T>> T handle(@NonNull JsonWebRequest request, Class<T> responseType) {
// TODO: handle rate limit handling for async reqs
return request.execute(clientConfig, responseType);
@NonNull private final BiConsumer<T, Exception> callback;
/**
* 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;
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);
}
}
}