11package com .bencodez .simpleapi .skull ;
22
33import java .util .Queue ;
4+ import java .util .Set ;
45import java .util .UUID ;
6+ import java .util .concurrent .ConcurrentHashMap ;
57import java .util .concurrent .ConcurrentLinkedQueue ;
68import java .util .concurrent .Executors ;
79import java .util .concurrent .ScheduledExecutorService ;
10+ import java .util .concurrent .ScheduledFuture ;
11+ import java .util .concurrent .ThreadLocalRandom ;
812import java .util .concurrent .TimeUnit ;
13+ import java .util .concurrent .atomic .AtomicInteger ;
14+ import java .util .concurrent .atomic .AtomicLong ;
915
1016import org .bukkit .Material ;
1117import org .bukkit .inventory .ItemStack ;
1420import lombok .Setter ;
1521
1622public abstract class SkullCacheHandler {
23+
1724 @ Getter
18- private int skullDelayTime = 4000 ;
25+ private volatile int skullDelayTime = 4000 ;
26+
27+ private volatile int currentDelayMs ;
1928
20- Queue <String > skullsToLoad = new ConcurrentLinkedQueue <>();
29+ private final Queue <String > skullsToLoad = new ConcurrentLinkedQueue <>();
30+ private final Set <String > queuedOrLoading = ConcurrentHashMap .newKeySet ();
2131
22- private boolean pause = false ;
32+ private volatile boolean paused = false ;
2333
2434 @ Getter
2535 @ Setter
2636 private String bedrockPrefix = "." ;
2737
28- private ScheduledExecutorService timer = Executors .newScheduledThreadPool (1 );
38+ private final ScheduledExecutorService timer = Executors .newScheduledThreadPool (1 );
39+ private volatile ScheduledFuture <?> workerFuture ;
2940
30- public SkullCacheHandler () {
41+ private final int minDelayMs = 250 ;
42+ private final int maxDelayMs = 30_000 ;
43+ private final int pauseMinSeconds = 120 ;
44+ private final int pauseMaxSeconds = 240 ;
45+
46+ private volatile int backoffMultiplier = 1 ;
47+
48+ private final long successDecayEveryMs = 60_000 ;
49+ private final AtomicLong lastErrorAt = new AtomicLong (0 );
50+ private final AtomicLong lastSuccessAt = new AtomicLong (0 );
51+
52+ private final AtomicInteger rateLimitHitCount = new AtomicInteger (0 );
3153
54+ public SkullCacheHandler () {
55+ this .currentDelayMs = clamp (skullDelayTime , minDelayMs , maxDelayMs );
3256 }
3357
3458 public SkullCacheHandler (int skullDelayTime ) {
3559 this .skullDelayTime = skullDelayTime ;
60+ this .currentDelayMs = clamp (skullDelayTime , minDelayMs , maxDelayMs );
3661 }
3762
3863 public void addToCache (UUID uuid , String name ) {
39- String text = uuid .toString () + "/" + name ;
40- if (uuid .toString ().charAt (14 ) == '3' ) {
41- debugLog ("Can't cache skull of offline player uuid: " + uuid .toString () + "/" + name );
64+ if (uuid == null || name == null ) {
65+ return ;
66+ }
67+
68+ String uuidStr = uuid .toString ();
69+
70+ if (uuidStr .length () > 14 && uuidStr .charAt (14 ) == '3' ) {
4271 return ;
4372 }
73+
4474 if (name .startsWith (bedrockPrefix )) {
45- debugLog ("Can't cache skull of bedrock player" );
4675 return ;
4776 }
48- if (!skullsToLoad .contains (text ) && !SkullCache .isLoaded (uuid )) {
49- if (name .length () <= 16 ) {
50- skullsToLoad .add (text );
51- } else {
52- debugLog ("Player name too long to preload skull: " + uuid .toString () + "/" + name );
53- }
77+
78+ if (name .length () > 16 ) {
79+ return ;
80+ }
81+
82+ if (SkullCache .isLoaded (uuid )) {
83+ return ;
84+ }
85+
86+ String key = uuidStr + "/" + name ;
87+
88+ if (queuedOrLoading .add (key )) {
89+ skullsToLoad .add (key );
5490 }
5591 }
5692
@@ -59,7 +95,13 @@ public void changeApiProfileURL(String url) {
5995 }
6096
6197 public void close () {
62- timer .shutdownNow ();
98+ try {
99+ if (workerFuture != null ) {
100+ workerFuture .cancel (false );
101+ }
102+ } finally {
103+ timer .shutdownNow ();
104+ }
63105 }
64106
65107 public abstract void debugException (Exception e );
@@ -74,65 +116,160 @@ public void flushCache() {
74116 public ItemStack getSkull (UUID uuid , String playerName ) {
75117 Material skullMaterial ;
76118 ItemStack skullItem ;
77- // Check for legacy versions
119+
78120 try {
79121 skullMaterial = Material .valueOf ("PLAYER_HEAD" );
80122 skullItem = new ItemStack (skullMaterial );
81123 } catch (IllegalArgumentException e ) {
82- // Fallback
83124 skullMaterial = Material .valueOf ("SKULL_ITEM" );
84125 skullItem = new ItemStack (skullMaterial , 1 , (short ) 3 );
85126 }
86- if (playerName .length () > 16 || (pause && !SkullCache .isLoaded (uuid )) || uuid .toString ().charAt (14 ) == '3' ) {
127+
128+ if (uuid == null || playerName == null ) {
87129 return skullItem ;
88130 }
131+
132+ String uuidStr = uuid .toString ();
133+
134+ if (playerName .length () > 16 ) {
135+ return skullItem ;
136+ }
137+
138+ if (paused && !SkullCache .isLoaded (uuid )) {
139+ return skullItem ;
140+ }
141+
142+ if (uuidStr .length () > 14 && uuidStr .charAt (14 ) == '3' ) {
143+ return skullItem ;
144+ }
145+
89146 try {
90147 return SkullCache .getSkull (uuid , playerName );
91148 } catch (Exception e ) {
92- pauseCaching ( );
149+ onCacheError ( e );
93150 return skullItem ;
94151 }
95152 }
96153
97154 public abstract void log (String log );
98155
99156 public void pauseCaching () {
100- log ("Pausing skull caching due to hitting rate limit or an error, increasing delay for caching" );
101- pause = true ;
102- skullDelayTime += 3000 ;
103- timer .schedule (new Runnable () {
104-
105- @ Override
106- public void run () {
107- unPuase ();
108- }
109- }, 15 , TimeUnit .MINUTES );
157+ onCacheError (null );
110158 }
111159
112160 public void startTimer () {
113- timer .scheduleWithFixedDelay (new Runnable () {
114-
115- @ Override
116- public void run () {
117- if (!skullsToLoad .isEmpty () && !pause ) {
118- String text = skullsToLoad .remove ();
119- try {
120- String [] data = text .split ("/" );
121- String uuid = data [0 ];
122- String name = data [1 ];
123- SkullCache .cacheSkull (UUID .fromString (uuid ), name );
124- debugLog ("Loaded skull: " + uuid + "/" + name );
125- } catch (Exception e ) {
126- debugLog ("Failed to load skull: " + text );
127- debugException (e );
128- pauseCaching ();
129- }
130- }
161+ scheduleWorker (60_000 , currentDelayMs );
162+ }
163+
164+ private void scheduleWorker (long initialDelayMs , int delayMs ) {
165+ int clamped = clamp (delayMs , minDelayMs , maxDelayMs );
166+ this .currentDelayMs = clamped ;
167+
168+ if (workerFuture != null ) {
169+ workerFuture .cancel (false );
170+ }
171+
172+ workerFuture = timer .scheduleWithFixedDelay (() -> {
173+ try {
174+ workerTick ();
175+ } catch (Exception e ) {
176+ debugException (e );
177+ }
178+ }, initialDelayMs , this .currentDelayMs , TimeUnit .MILLISECONDS );
179+ }
180+
181+ private void workerTick () {
182+ if (paused ) {
183+ tryDecayBackoff ();
184+ return ;
185+ }
186+
187+ String text = skullsToLoad .poll ();
188+ if (text == null ) {
189+ tryDecayBackoff ();
190+ return ;
191+ }
192+
193+ try {
194+ String [] data = text .split ("/" , 2 );
195+ if (data .length != 2 ) {
196+ return ;
131197 }
132- }, 1000 * 60 , skullDelayTime , TimeUnit .MILLISECONDS );
198+
199+ SkullCache .cacheSkull (UUID .fromString (data [0 ]), data [1 ]);
200+ debugLog ("Skull cached: " + data [0 ] + "/" + data [1 ] + " (queue=" + skullsToLoad .size () + ")" );
201+ lastSuccessAt .set (System .currentTimeMillis ());
202+ tryDecayBackoff ();
203+
204+ } catch (Exception e ) {
205+ debugException (e );
206+ onCacheError (e );
207+ } finally {
208+ queuedOrLoading .remove (text );
209+ }
210+ }
211+
212+ private void onCacheError (Exception e ) {
213+ long now = System .currentTimeMillis ();
214+ lastErrorAt .set (now );
215+
216+ int hits = rateLimitHitCount .incrementAndGet ();
217+
218+ backoffMultiplier = Math .min (backoffMultiplier * 2 , 64 );
219+
220+ int base = clamp (skullDelayTime , minDelayMs , maxDelayMs );
221+ int newDelay = clamp (base * backoffMultiplier , minDelayMs , maxDelayMs );
222+ newDelay = addJitter (newDelay , 0.15 );
223+
224+ int pauseSeconds = ThreadLocalRandom .current ().nextInt (pauseMinSeconds , pauseMaxSeconds + 1 );
225+ paused = true ;
226+
227+ if (hits == 1 ) {
228+ debugLog ("Skull caching rate limit detected. Pausing for ~" + pauseSeconds + "s." );
229+ } else {
230+ log ("Skull caching still rate limited (" + hits + " hits). Delay now " + newDelay + "ms." );
231+ }
232+
233+ scheduleWorker (0 , newDelay );
234+
235+ timer .schedule (() -> paused = false , pauseSeconds , TimeUnit .SECONDS );
236+ }
237+
238+ private void tryDecayBackoff () {
239+ long now = System .currentTimeMillis ();
240+
241+ long lastErr = lastErrorAt .get ();
242+ if (lastErr != 0 && (now - lastErr ) < successDecayEveryMs ) {
243+ return ;
244+ }
245+
246+ long lastOk = lastSuccessAt .get ();
247+ if (lastOk == 0 || (now - lastOk ) < successDecayEveryMs ) {
248+ return ;
249+ }
250+
251+ if (backoffMultiplier > 1 ) {
252+ backoffMultiplier = Math .max (1 , backoffMultiplier / 2 );
253+
254+ int base = clamp (skullDelayTime , minDelayMs , maxDelayMs );
255+ int newDelay = clamp (base * backoffMultiplier , minDelayMs , maxDelayMs );
256+ newDelay = addJitter (newDelay , 0.10 );
257+
258+ if (Math .abs (newDelay - currentDelayMs ) >= 50 ) {
259+ scheduleWorker (0 , newDelay );
260+ }
261+
262+ lastSuccessAt .set (now );
263+ }
264+ }
265+
266+ private static int clamp (int v , int min , int max ) {
267+ return Math .max (min , Math .min (max , v ));
133268 }
134269
135- private void unPuase () {
136- pause = false ;
270+ private static int addJitter (int valueMs , double pct ) {
271+ int spread = (int ) Math .max (1 , Math .round (valueMs * pct ));
272+ int delta = ThreadLocalRandom .current ().nextInt (-spread , spread + 1 );
273+ return Math .max (1 , valueMs + delta );
137274 }
138275}
0 commit comments