````java package org.itqop.whitelist; import com.google.gson.*; import com.mojang.logging.LogUtils; import org.slf4j.Logger; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.Objects; import java.util.concurrent.CompletableFuture; public class WhitelistApiClient { private static final WhitelistApiClient INSTANCE = new WhitelistApiClient(); public static WhitelistApiClient get() { return INSTANCE; } public static void refreshConfigFromSpec() { INSTANCE.refreshFromConfig(); } private static final Logger LOGGER = LogUtils.getLogger(); private static final Gson GSON = new GsonBuilder().create(); // Circuit breaker to prevent log spam private volatile long lastErrorLogTime = 0; private volatile int consecutiveErrors = 0; private static final long ERROR_LOG_COOLDOWN_MS = 60_000; // 1 minute private static final int ERROR_THRESHOLD = 3; private volatile HttpClient http; private volatile String baseUrl; public WhitelistApiClient() { this.http = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_1_1) .connectTimeout(Duration.ofSeconds(Math.max(1, Config.requestTimeout))) .build(); this.baseUrl = normalizeBaseUrl(Config.apiBaseUrl); } public void refreshFromConfig() { this.baseUrl = normalizeBaseUrl(Config.apiBaseUrl); } private static String normalizeBaseUrl(String url) { if (url == null || url.isBlank()) return ""; String u = url.trim(); if (u.endsWith("/")) u = u.substring(0, u.length() - 1); return u; } private HttpRequest.Builder baseBuilder(String path) { return HttpRequest.newBuilder(URI.create(baseUrl + path)) .timeout(Duration.ofSeconds(Math.max(1, Config.requestTimeout))) .header("Content-Type", "application/json") .header("Accept", "application/json") .header("X-API-Key", Config.apiKey); } public CompletableFuture addPlayer(String playerName, String reason) { return addPlayer(playerName, null, null); } public CompletableFuture addPlayer(String playerName, String addedBy, String addedAtIso) { Objects.requireNonNull(playerName, "playerName"); JsonObject body = new JsonObject(); body.addProperty("player_name", playerName); if (addedBy != null && !addedBy.isBlank()) body.addProperty("added_by", addedBy); if (addedAtIso != null && !addedAtIso.isBlank()) body.addProperty("added_at", addedAtIso); return makeRequest("POST", "/add", HttpRequest.BodyPublishers.ofString(GSON.toJson(body))) .thenApply(resp -> { if (resp == null) return false; int code = resp.statusCode(); if (code == 201) { resetErrorCounter(); return true; } logHttpError("POST /add", code, resp.body()); return false; }) .exceptionally(ex -> { LOGGER.error("Failed to add player '{}': {}", playerName, ex.getMessage(), ex); return false; }); } public CompletableFuture addPlayer(String playerName, String addedBy, Instant addedAt, Instant expiresAt, Boolean isActive, String reason) { Objects.requireNonNull(playerName, "playerName"); JsonObject body = new JsonObject(); body.addProperty("player_name", playerName); if (addedBy != null && !addedBy.isBlank()) body.addProperty("added_by", addedBy); if (addedAt != null) body.addProperty("added_at", addedAt.toString()); if (expiresAt != null) body.addProperty("expires_at", expiresAt.toString()); if (isActive != null) body.addProperty("is_active", isActive); if (reason != null && !reason.isBlank()) body.addProperty("reason", reason); return makeRequest("POST", "/add", HttpRequest.BodyPublishers.ofString(GSON.toJson(body))) .thenApply(resp -> { if (resp == null) return null; int code = resp.statusCode(); if (code != 201) { logHttpError("POST /add", code, resp.body()); return null; } try { resetErrorCounter(); JsonObject json = JsonParser.parseString(resp.body()).getAsJsonObject(); return WhitelistEntry.fromJson(json); } catch (Exception e) { LOGGER.error("Failed to parse addPlayer response for '{}': {}", playerName, e.getMessage(), e); return null; } }) .exceptionally(ex -> { LOGGER.error("Failed to add player '{}': {}", playerName, ex.getMessage(), ex); return null; }); } public CompletableFuture removePlayer(String playerName) { Objects.requireNonNull(playerName, "playerName"); JsonObject body = new JsonObject(); body.addProperty("player_name", playerName); return makeRequest("POST", "/remove", HttpRequest.BodyPublishers.ofString(GSON.toJson(body))) .thenApply(resp -> { if (resp == null) return false; int code = resp.statusCode(); if (code == 204) { resetErrorCounter(); return true; } logHttpError("POST /remove", code, resp.body()); return false; }) .exceptionally(ex -> { LOGGER.error("Failed to remove player '{}': {}", playerName, ex.getMessage(), ex); return false; }); } public CompletableFuture checkPlayer(String playerName) { Objects.requireNonNull(playerName, "playerName"); JsonObject body = new JsonObject(); body.addProperty("player_name", playerName); return makeRequest("POST", "/check", HttpRequest.BodyPublishers.ofString(GSON.toJson(body))) .thenApply(response -> { if (response == null) return new CheckResponse(false, false); try { int code = response.statusCode(); if (code < 200 || code >= 300) { logHttpError("POST /check", code, response.body()); return new CheckResponse(false, false); } resetErrorCounter(); JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject(); boolean isWhitelisted = json.has("is_whitelisted") && json.get("is_whitelisted").getAsBoolean(); return new CheckResponse(true, isWhitelisted); } catch (Exception e) { LOGGER.warn("Failed to parse checkPlayer response for '{}': {}", playerName, e.getMessage()); return new CheckResponse(false, false); } }) .exceptionally(ex -> { logApiError("Failed to check player '" + playerName + "'", ex); return new CheckResponse(false, false); }); } public CompletableFuture listAll() { return makeRequest("GET", "/", HttpRequest.BodyPublishers.noBody()) .thenApply(resp -> { if (resp == null) return null; int code = resp.statusCode(); if (code < 200 || code >= 300) { logHttpError("GET /", code, resp.body()); return null; } try { JsonObject json = JsonParser.parseString(resp.body()).getAsJsonObject(); JsonArray arr = json.getAsJsonArray("entries"); int total = json.has("total") ? json.get("total").getAsInt() : 0; WhitelistEntry[] entries = new WhitelistEntry[arr.size()]; for (int i = 0; i < arr.size(); i++) { entries[i] = WhitelistEntry.fromJson(arr.get(i).getAsJsonObject()); } return new WhitelistListResponse(List.of(entries), total); } catch (Exception e) { LOGGER.error("Failed to parse listAll response: {}", e.getMessage(), e); return null; } }) .exceptionally(ex -> { LOGGER.error("Failed to list whitelist entries: {}", ex.getMessage(), ex); return null; }); } public CompletableFuture count() { return makeRequest("GET", "/count", HttpRequest.BodyPublishers.noBody()) .thenApply(resp -> { if (resp == null) return null; int code = resp.statusCode(); if (code < 200 || code >= 300) { logHttpError("GET /count", code, resp.body()); return null; } try { JsonObject json = JsonParser.parseString(resp.body()).getAsJsonObject(); return json.has("total") ? json.get("total").getAsInt() : 0; } catch (Exception e) { LOGGER.error("Failed to parse count response: {}", e.getMessage(), e); return null; } }) .exceptionally(ex -> { LOGGER.error("Failed to get whitelist count: {}", ex.getMessage(), ex); return null; }); } private CompletableFuture> makeRequest(String method, String path, HttpRequest.BodyPublisher body) { HttpRequest.Builder b = baseBuilder(path); if ("POST".equalsIgnoreCase(method)) b.POST(body); else if ("PUT".equalsIgnoreCase(method)) b.PUT(body); else if ("DELETE".equalsIgnoreCase(method)) b.method("DELETE", body); else b.method(method, body); return http.sendAsync(b.build(), HttpResponse.BodyHandlers.ofString()); } private void logHttpError(String endpoint, int statusCode, String responseBody) { consecutiveErrors++; // Log only first error and then once per minute to prevent spam long now = System.currentTimeMillis(); if (consecutiveErrors == 1 || (now - lastErrorLogTime) > ERROR_LOG_COOLDOWN_MS) { if (Config.enableLogging) { LOGGER.warn("API request failed: {} returned {} - Response: {}", endpoint, statusCode, responseBody); } else { LOGGER.warn("API request failed: {} returned {}", endpoint, statusCode); } lastErrorLogTime = now; if (consecutiveErrors > ERROR_THRESHOLD) { LOGGER.warn("API has failed {} times consecutively. Further errors will be throttled.", consecutiveErrors); } } } private void logApiError(String message, Throwable ex) { consecutiveErrors++; // Log only first error and then once per minute to prevent spam long now = System.currentTimeMillis(); if (consecutiveErrors == 1 || (now - lastErrorLogTime) > ERROR_LOG_COOLDOWN_MS) { LOGGER.warn("{}: {} (errors: {})", message, ex.getMessage(), consecutiveErrors); lastErrorLogTime = now; if (consecutiveErrors > ERROR_THRESHOLD) { LOGGER.warn("API has failed {} times consecutively. Further errors will be throttled.", consecutiveErrors); } } } private void resetErrorCounter() { if (consecutiveErrors > 0) { LOGGER.info("API connection restored after {} consecutive errors", consecutiveErrors); consecutiveErrors = 0; } } public static final class CheckResponse { private final boolean success; private final boolean isWhitelisted; public CheckResponse(boolean success, boolean isWhitelisted) { this.success = success; this.isWhitelisted = isWhitelisted; } public boolean isSuccess() { return success; } public boolean isWhitelisted() { return isWhitelisted; } } public static final class WhitelistEntry { public final String id; public final String playerName; public final String addedBy; public final String addedAt; public final String expiresAt; public final boolean isActive; public final String reason; public WhitelistEntry(String id, String playerName, String addedBy, String addedAt, String expiresAt, boolean isActive, String reason) { this.id = id; this.playerName = playerName; this.addedBy = addedBy; this.addedAt = addedAt; this.expiresAt = expiresAt; this.isActive = isActive; this.reason = reason; } public static WhitelistEntry fromJson(JsonObject json) { String id = json.has("id") && !json.get("id").isJsonNull() ? json.get("id").getAsString() : null; String pn = json.has("player_name") && !json.get("player_name").isJsonNull() ? json.get("player_name").getAsString() : null; String by = json.has("added_by") && !json.get("added_by").isJsonNull() ? json.get("added_by").getAsString() : null; String at = json.has("added_at") && !json.get("added_at").isJsonNull() ? json.get("added_at").getAsString() : null; String exp = json.has("expires_at") && !json.get("expires_at").isJsonNull() ? json.get("expires_at").getAsString() : null; boolean active = json.has("is_active") && !json.get("is_active").isJsonNull() && json.get("is_active").getAsBoolean(); String rsn = json.has("reason") && !json.get("reason").isJsonNull() ? json.get("reason").getAsString() : null; return new WhitelistEntry(id, pn, by, at, exp, active, rsn); } } public static final class WhitelistListResponse { public final List entries; public final int total; public WhitelistListResponse(List entries, int total) { this.entries = entries; this.total = total; } } } ```