Repository: javacord/javacord
Issue: The 50/1 global REST ratelimit is not handled properly

At the moment, hitting the global 50/1 ratelimit (e.g. by trying to send 100 DMs to 100 different users at the same time) is not handled properly by Javacord.

Changed files:
javacord-core/src/main/java/org/javacord/core/entity/channel/InternalTextChannel.java
javacord-core/src/main/java/org/javacord/core/DiscordApiImpl.java
javacord-core/src/main/java/org/javacord/core/util/ratelimit/RatelimitManager.java
javacord-core/src/main/java/org/javacord/core/util/ratelimit/RatelimitBucket.java
javacord-core/src/main/java/org/javacord/core/util/rest/RestRequest.java
javacord-core/src/main/java/org/javacord/core/entity/message/UncachedMessageUtilImpl.java
javacord-core/src/main/java/org/javacord/core/AccountUpdaterDelegateImpl.java

diff --git a/javacord-core/src/main/java/org/javacord/core/AccountUpdaterDelegateImpl.java b/javacord-core/src/main/java/org/javacord/core/AccountUpdaterDelegateImpl.java
index 1f5fe90a..606e9af7 100644
--- a/javacord-core/src/main/java/org/javacord/core/AccountUpdaterDelegateImpl.java
+++ b/javacord-core/src/main/java/org/javacord/core/AccountUpdaterDelegateImpl.java
@@ -113,12 +113,10 @@ public class AccountUpdaterDelegateImpl implements AccountUpdaterDelegate {
                             + Base64.getEncoder().encodeToString(bytes);
                     body.put("avatar", base64Avatar);
                 }).thenCompose(aVoid -> new RestRequest<Void>(api, RestMethod.PATCH, RestEndpoint.CURRENT_USER)
-                        .setRatelimitRetries(0)
                         .setBody(body)
                         .execute(result -> null));
             }
             return new RestRequest<Void>(api, RestMethod.PATCH, RestEndpoint.CURRENT_USER)
-                    .setRatelimitRetries(0)
                     .setBody(body)
                     .execute(result -> null);
         } else {
diff --git a/javacord-core/src/main/java/org/javacord/core/DiscordApiImpl.java b/javacord-core/src/main/java/org/javacord/core/DiscordApiImpl.java
index ace50cd4..6974db4f 100644
--- a/javacord-core/src/main/java/org/javacord/core/DiscordApiImpl.java
+++ b/javacord-core/src/main/java/org/javacord/core/DiscordApiImpl.java
@@ -1059,7 +1059,6 @@ public class DiscordApiImpl implements DiscordApi, InternalGloballyAttachableLis
                 }
                 httpClient.dispatcher().executorService().shutdown();
                 httpClient.connectionPool().evictAll();
-                ratelimitManager.cleanup();
             }
             disconnectCalled = true;
         }
diff --git a/javacord-core/src/main/java/org/javacord/core/entity/channel/InternalTextChannel.java b/javacord-core/src/main/java/org/javacord/core/entity/channel/InternalTextChannel.java
index 7a95ff3a..7168582a 100644
--- a/javacord-core/src/main/java/org/javacord/core/entity/channel/InternalTextChannel.java
+++ b/javacord-core/src/main/java/org/javacord/core/entity/channel/InternalTextChannel.java
@@ -32,7 +32,6 @@ public interface InternalTextChannel extends TextChannel, InternalTextChannelAtt
     @Override
     default CompletableFuture<Void> type() {
         return new RestRequest<Void>(getApi(), RestMethod.POST, RestEndpoint.CHANNEL_TYPING)
-                .setRatelimitRetries(0)
                 .setUrlParameters(getIdAsString())
                 .execute(result -> null);
     }
diff --git a/javacord-core/src/main/java/org/javacord/core/entity/message/UncachedMessageUtilImpl.java b/javacord-core/src/main/java/org/javacord/core/entity/message/UncachedMessageUtilImpl.java
index 1ed0e33b..f87bcf75 100644
--- a/javacord-core/src/main/java/org/javacord/core/entity/message/UncachedMessageUtilImpl.java
+++ b/javacord-core/src/main/java/org/javacord/core/entity/message/UncachedMessageUtilImpl.java
@@ -61,7 +61,6 @@ public class UncachedMessageUtilImpl implements UncachedMessageUtil, InternalUnc
     public CompletableFuture<Void> delete(long channelId, long messageId, String reason) {
         return new RestRequest<Void>(api, RestMethod.DELETE, RestEndpoint.MESSAGE_DELETE)
                 .setUrlParameters(Long.toUnsignedString(channelId), Long.toUnsignedString(messageId))
-                .setRatelimitRetries(250)
                 .setAuditLogReason(reason)
                 .execute(result -> null);
     }
@@ -249,7 +248,6 @@ public class UncachedMessageUtilImpl implements UncachedMessageUtil, InternalUnc
         return new RestRequest<Void>(api, RestMethod.PUT, RestEndpoint.REACTION)
                 .setUrlParameters(
                         Long.toUnsignedString(channelId), Long.toUnsignedString(messageId), unicodeEmoji, "@me")
-                .setRatelimitRetries(500)
                 .execute(result -> null);
     }
 
@@ -274,7 +272,6 @@ public class UncachedMessageUtilImpl implements UncachedMessageUtil, InternalUnc
         );
         return new RestRequest<Void>(api, RestMethod.PUT, RestEndpoint.REACTION)
                 .setUrlParameters(Long.toUnsignedString(channelId), Long.toUnsignedString(messageId), value, "@me")
-                .setRatelimitRetries(500)
                 .execute(result -> null);
     }
 
@@ -357,8 +354,7 @@ public class UncachedMessageUtilImpl implements UncachedMessageUtil, InternalUnc
                             new RestRequest<List<User>>(api, RestMethod.GET, RestEndpoint.REACTION)
                                     .setUrlParameters(
                                             Long.toUnsignedString(channelId), Long.toUnsignedString(messageId), value)
-                                    .addQueryParameter("limit", "100")
-                                    .setRatelimitRetries(250);
+                                    .addQueryParameter("limit", "100");
                     if (!users.isEmpty()) {
                         request.addQueryParameter("after", users.get(users.size() - 1).getIdAsString());
                     }
@@ -401,7 +397,6 @@ public class UncachedMessageUtilImpl implements UncachedMessageUtil, InternalUnc
                         Long.toUnsignedString(messageId),
                         value,
                         user.isYourself() ? "@me" : user.getIdAsString())
-                .setRatelimitRetries(250)
                 .execute(result -> null);
     }
 
diff --git a/javacord-core/src/main/java/org/javacord/core/util/ratelimit/RatelimitBucket.java b/javacord-core/src/main/java/org/javacord/core/util/ratelimit/RatelimitBucket.java
index 0be1cc07..915ec879 100644
--- a/javacord-core/src/main/java/org/javacord/core/util/ratelimit/RatelimitBucket.java
+++ b/javacord-core/src/main/java/org/javacord/core/util/ratelimit/RatelimitBucket.java
@@ -3,20 +3,26 @@ package org.javacord.core.util.ratelimit;
 import org.javacord.api.DiscordApi;
 import org.javacord.core.DiscordApiImpl;
 import org.javacord.core.util.rest.RestEndpoint;
+import org.javacord.core.util.rest.RestRequest;
 
-import java.util.Optional;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
 
 public class RatelimitBucket {
 
+    // The key is the token, as global ratelimits are shared across the same account.
+    private static final Map<String, Long> globalRatelimitResetTimestamp = new ConcurrentHashMap<>();
+
     private final DiscordApiImpl api;
 
+    private final ConcurrentLinkedQueue<RestRequest<?>> requestQueue = new ConcurrentLinkedQueue<>();
+
     private final RestEndpoint endpoint;
     private final String majorUrlParameter;
 
-    private volatile long rateLimitResetTimestamp = 0;
-    private volatile int rateLimitRemaining = 1;
-
-    private volatile boolean hasActiveScheduler = false;
+    private volatile long ratelimitResetTimestamp = 0;
+    private volatile int ratelimitRemaining = 1;
 
     /**
      * Creates a RatelimitBucket for the given endpoint / parameter combination.
@@ -45,58 +51,58 @@ public class RatelimitBucket {
     }
 
     /**
-     * Gets the rest endpoint of the bucket.
+     * Sets a global ratelimit.
      *
-     * @return The endpoint of the bucket. If it's a global limit, the endpoint will be not be present.
+     * @param api A discord api instance.
+     * @param resetTimestamp The reset timestamp of the global ratelimit.
      */
-    public Optional<RestEndpoint> getEndpoint() {
-        return Optional.ofNullable(endpoint);
+    public static void setGlobalRatelimitResetTimestamp(DiscordApi api, long resetTimestamp) {
+        globalRatelimitResetTimestamp.put(api.getToken(), resetTimestamp);
     }
 
     /**
-     * Checks if this bucket has an active scheduler.
+     * Adds the given request to the bucket's queue.
      *
-     * @return Whether this bucket has an active scheduler or not.
+     * @param request The request to add.
      */
-    public synchronized boolean hasActiveScheduler() {
-        return hasActiveScheduler;
+    public void addRequestToQueue(RestRequest<?> request) {
+        requestQueue.add(request);
     }
 
     /**
-     * Sets if this bucket has an active scheduler.
+     * Polls a request from the bucket's queue.
      *
-     * @param hasActiveScheduler Whether this bucket has an active scheduler or not.
+     * @return The polled request.
      */
-    public synchronized void setHasActiveScheduler(boolean hasActiveScheduler) {
-        this.hasActiveScheduler = hasActiveScheduler;
+    public RestRequest<?> pollRequestFromQueue() {
+        return requestQueue.poll();
     }
 
     /**
-     * Checks if there is still "space" in this bucket, which means that you can still send requests without being
-     * ratelimited.
+     * Peeks a request from the bucket's queue.
      *
-     * @return Whether you can send requests without being ratelimited or not.
+     * @return The peeked request.
      */
-    public boolean hasSpace() {
-        return rateLimitRemaining > 0 || getTimeTillSpaceGetsAvailable() <= 0;
+    public RestRequest<?> peekRequestFromQueue() {
+        return requestQueue.peek();
     }
 
     /**
      * Sets the remaining requests till ratelimit.
      *
-     * @param rateLimitRemaining The remaining requests till ratelimit.
+     * @param ratelimitRemaining The remaining requests till ratelimit.
      */
-    public void setRateLimitRemaining(int rateLimitRemaining) {
-        this.rateLimitRemaining = rateLimitRemaining;
+    public void setRatelimitRemaining(int ratelimitRemaining) {
+        this.ratelimitRemaining = ratelimitRemaining;
     }
 
     /**
      * Sets the ratelimit reset timestamp.
      *
-     * @param rateLimitResetTimestamp The rateLimit reset timestamp.
+     * @param ratelimitResetTimestamp The ratelimit reset timestamp.
      */
-    public void setRateLimitResetTimestamp(long rateLimitResetTimestamp) {
-        this.rateLimitResetTimestamp = rateLimitResetTimestamp;
+    public void setRatelimitResetTimestamp(long ratelimitResetTimestamp) {
+        this.ratelimitResetTimestamp = ratelimitResetTimestamp;
     }
 
     /**
@@ -105,11 +111,13 @@ public class RatelimitBucket {
      * @return The time in seconds how long you have to wait till there's space in the bucket again.
      */
     public int getTimeTillSpaceGetsAvailable() {
-        if (rateLimitRemaining > 0) {
+        long globalRatelimitResetTimestamp =
+                RatelimitBucket.globalRatelimitResetTimestamp.getOrDefault(api.getToken(), 0L);
+        long timestamp = System.currentTimeMillis() + (api.getTimeOffset() == null ? 0 : api.getTimeOffset());
+        if (ratelimitRemaining > 0 && (globalRatelimitResetTimestamp - timestamp) <= 0) {
             return 0;
         }
-        long timestamp = System.currentTimeMillis() + (api.getTimeOffset() == null ? 0 : api.getTimeOffset());
-        return (int) (rateLimitResetTimestamp - timestamp);
+        return (int) (Math.max(ratelimitResetTimestamp, globalRatelimitResetTimestamp) - timestamp);
     }
 
     /**
diff --git a/javacord-core/src/main/java/org/javacord/core/util/ratelimit/RatelimitManager.java b/javacord-core/src/main/java/org/javacord/core/util/ratelimit/RatelimitManager.java
index fa582713..66dc2555 100644
--- a/javacord-core/src/main/java/org/javacord/core/util/ratelimit/RatelimitManager.java
+++ b/javacord-core/src/main/java/org/javacord/core/util/ratelimit/RatelimitManager.java
@@ -2,12 +2,8 @@ package org.javacord.core.util.ratelimit;
 
 import okhttp3.Response;
 import org.apache.logging.log4j.Logger;
-import org.javacord.api.DiscordApi;
 import org.javacord.api.exception.DiscordException;
-import org.javacord.api.exception.RatelimitException;
 import org.javacord.core.DiscordApiImpl;
-import org.javacord.core.util.Cleanupable;
-import org.javacord.core.util.concurrent.ThreadFactory;
 import org.javacord.core.util.logging.LoggerUtil;
 import org.javacord.core.util.rest.RestRequest;
 import org.javacord.core.util.rest.RestRequestResponseInformationImpl;
@@ -15,208 +11,196 @@ import org.javacord.core.util.rest.RestRequestResult;
 
 import java.time.OffsetDateTime;
 import java.time.format.DateTimeFormatter;
-import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ConcurrentLinkedQueue;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
 import java.util.function.Function;
 
 /**
  * This class manages ratelimits and keeps track of them.
  */
-public class RatelimitManager implements Cleanupable {
+public class RatelimitManager {
 
     /**
      * The logger of this class.
      */
     private static final Logger logger = LoggerUtil.getLogger(RatelimitManager.class);
 
-    private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(
-            1, new ThreadFactory("Javacord - RatelimitBucket Delay Scheduler - %d", true));
-
-    private final Set<RatelimitBucket> buckets = ConcurrentHashMap.newKeySet();
-    private final HashMap<RatelimitBucket, ConcurrentLinkedQueue<RestRequest<?>>> queues = new HashMap<>();
-
+    /**
+     * The discord api instance for this ratelimit manager.
+     */
     private final DiscordApiImpl api;
 
     /**
-     * Creates a new ratelimit manager for the given api.
+     * A set with all buckets.
+     */
+    private final Set<RatelimitBucket> buckets = new HashSet<>();
+
+    /**
+     * Creates a new ratelimit manager.
      *
-     * @param api The api instance of the bot.
+     * @param api The discord api instance for this ratelimit manager.
      */
-    public RatelimitManager(DiscordApi api) {
-        this.api = (DiscordApiImpl) api;
+    public RatelimitManager(DiscordApiImpl api) {
+        this.api = api;
     }
 
     /**
-     * Adds a request to the queue based on the ratelimit bucket.
+     * Queues the given request.
      * This method is automatically called when using {@link RestRequest#execute(Function)}!
      *
      * @param request The request to queue.
      */
     public void queueRequest(RestRequest<?> request) {
-        // Get the bucket for the current request type.
-        RatelimitBucket bucket = buckets
-                .parallelStream()
-                .filter(b -> b.equals(request.getEndpoint(), request.getMajorUrlParameter().orElse(null)))
-                .findAny()
-                .orElseGet(() ->
-                        new RatelimitBucket(api, request.getEndpoint(), request.getMajorUrlParameter().orElse(null)));
-
-        // Add bucket to list with buckets
-        buckets.add(bucket);
-
-        // Get the queue for the current bucket or create a new one if there's no one already
-        ConcurrentLinkedQueue<RestRequest<?>> queue =
-                queues.computeIfAbsent(bucket, k -> new ConcurrentLinkedQueue<>());
-
-        // Add the request to the queue and check if there's already a scheduler working on the queue
-        boolean startScheduler = false;
-        synchronized (bucket) {
-            synchronized (queue) {
-                if (bucket.hasActiveScheduler()) {
-                    queue.add(request);
-                } else {
-                    bucket.setHasActiveScheduler(true);
-                    queue.add(request);
-                    startScheduler = true;
-                }
-            }
+        final RatelimitBucket bucket;
+        final boolean alreadyInQueue;
+        synchronized (buckets) {
+            // Search for a bucket that fits to this request
+            bucket = buckets.stream()
+                    .filter(b -> b.equals(request.getEndpoint(), request.getMajorUrlParameter().orElse(null)))
+                    .findAny()
+                    .orElseGet(() -> new RatelimitBucket(
+                            api, request.getEndpoint(), request.getMajorUrlParameter().orElse(null)));
+
+            // Must be executed BEFORE adding the request to the queue
+            alreadyInQueue = bucket.peekRequestFromQueue() != null;
+
+            // Add the bucket to the set of buckets (does nothing if it's already in the set)
+            buckets.add(bucket);
+
+            // Add the request to the bucket's queue
+            bucket.addRequestToQueue(request);
         }
 
-        if (!startScheduler) {
+        // If the bucket is already in the queue, there's nothing more to do
+        if (alreadyInQueue) {
             return;
         }
-        int delay = bucket.getTimeTillSpaceGetsAvailable();
-        if (delay > 0) {
-            synchronized (bucket) {
-                synchronized (queue) {
-                    if (request.incrementRetryCounter()) {
-                        request.getResult().completeExceptionally(
-                                new RatelimitException(request.getOrigin(),
-                                        "You have been ratelimited and ran out of retires!",
-                                        request.asRestRequestInformation())
-                        );
-                        queue.remove(request);
-                        bucket.setHasActiveScheduler(false);
-                        return;
+
+        // Start working of the queue
+        api.getThreadPool().getExecutorService().submit(() -> {
+            RestRequest<?> currentRequest = bucket.peekRequestFromQueue();
+            RestRequestResult result = null;
+            long responseTimestamp = System.currentTimeMillis();
+            while (currentRequest != null) {
+                try {
+                    int sleepTime = bucket.getTimeTillSpaceGetsAvailable();
+                    if (sleepTime > 0) {
+                        logger.debug("Delaying requests to {} for {}ms to prevent hitting ratelimits",
+                                bucket, sleepTime);
                     }
-                }
-            }
-            logger.debug("Delaying requests to {} for {}ms to prevent hitting ratelimits", bucket, delay);
-        }
-        // Start a scheduler to work off the queue
-        scheduler.schedule(() -> api.getThreadPool().getExecutorService().submit(() -> {
-            try {
-                while (!queue.isEmpty()) {
-                    if (!bucket.hasSpace()) {
-                        synchronized (queue) {
-                            // Remove if we retried to often
-                            queue.removeIf(req -> {
-                                if (req.incrementRetryCounter()) {
-                                    req.getResult().completeExceptionally(
-                                            new RatelimitException(req.getOrigin(),
-                                                    "You have been ratelimited and ran out of retires!",
-                                                    request.asRestRequestInformation())
-                                    );
-                                    return true;
-                                }
-                                return false;
-                            });
-                            if (queue.isEmpty()) {
-                                break;
-                            }
-                        }
+
+                    // Sleep until space is available
+                    while (sleepTime > 0) {
                         try {
-                            int sleepTime = bucket.getTimeTillSpaceGetsAvailable();
-                            if (sleepTime > 0) {
-                                logger.debug("Delaying requests to {} for {}ms to prevent hitting ratelimits",
-                                        bucket, sleepTime);
-                                Thread.sleep(sleepTime);
-                            }
+                            Thread.sleep(sleepTime);
                         } catch (InterruptedException e) {
                             logger.warn("We got interrupted while waiting for a rate limit!", e);
                         }
+                        // Update in case something changed (e.g. because we hit a global ratelimit)
+                        sleepTime = bucket.getTimeTillSpaceGetsAvailable();
                     }
-                    RestRequest<?> restRequest = queue.peek();
-                    boolean remove = true;
-                    RestRequestResult rateLimitHeadersSource = null;
-                    CompletableFuture<RestRequestResult> restRequestResult = restRequest.getResult();
-                    try {
-                        RestRequestResult result = restRequest.executeBlocking();
-                        rateLimitHeadersSource = result;
-
-                        long currentTime = System.currentTimeMillis();
 
-                        if (api.getTimeOffset() == null) {
-                            calculateOffset(currentTime, result);
-                        }
+                    // Execute the request
+                    result = currentRequest.executeBlocking();
 
-                        if (result.getResponse().code() == 429) {
-                            remove = false;
-                            rateLimitHeadersSource = null;
-                            logger.debug("Received a 429 response from Discord! Recalculating time offset...");
-                            api.setTimeOffset(null);
-
-                            int retryAfter =
-                                    result.getJsonBody().isNull() ? 0 : result.getJsonBody().get("retry_after").asInt();
-                            bucket.setRateLimitRemaining(0);
-                            bucket.setRateLimitResetTimestamp(currentTime + retryAfter);
-                        } else {
-                            restRequestResult.complete(result);
-                        }
+                    // Calculate the time offset, if it wasn't done before
+                    responseTimestamp = System.currentTimeMillis();
+                } catch (Throwable t) {
+                    responseTimestamp = System.currentTimeMillis();
+                    if (currentRequest.getResult().isDone()) {
+                        logger.warn("Received exception for a request that is already done. "
+                                + "This should not be able to happen!", t);
+                    }
+                    // Try to get the response from the exception if it exists
+                    if (t instanceof DiscordException) {
+                        result = ((DiscordException) t).getResponse()
+                                .map(RestRequestResponseInformationImpl.class::cast)
+                                .map(RestRequestResponseInformationImpl::getRestRequestResult)
+                                .orElse(null);
+                    }
+                    // Complete the request
+                    currentRequest.getResult().completeExceptionally(t);
+                } finally {
+                    try {
+                        // Calculate offset
+                        calculateOffset(responseTimestamp, result);
+                        // Handle the response
+                        handleResponse(currentRequest, result, bucket, responseTimestamp);
                     } catch (Throwable t) {
-                        if (t instanceof DiscordException) {
-                            rateLimitHeadersSource = ((DiscordException) t).getResponse()
-                                    .map(response -> ((RestRequestResponseInformationImpl) response))
-                                    .map(RestRequestResponseInformationImpl::getRestRequestResult)
-                                    .orElse(null);
-                        }
-                        restRequestResult.completeExceptionally(t);
-                    } finally {
-                        try {
-                            if (rateLimitHeadersSource != null) {
-                                Response response = rateLimitHeadersSource.getResponse();
-                                String remaining = response.header("X-RateLimit-Remaining", "1");
-                                long reset = restRequest
-                                        .getEndpoint()
-                                        .getHardcodedRatelimit()
-                                        .map(ratelimit -> System.currentTimeMillis() + api.getTimeOffset() + ratelimit)
-                                        .orElseGet(
-                                                () -> Long.parseLong(response.header("X-RateLimit-Reset", "0")) * 1000
-                                        );
-                                String global = response.header("X-RateLimit-Global");
-
-                                if (global != null && global.equals("true")) {
-                                    // Mark the endpoint as global
-                                    bucket.getEndpoint().ifPresent(endpoint -> endpoint.setGlobal(true));
-                                }
-                                bucket.setRateLimitRemaining(Integer.parseInt(remaining));
-                                bucket.setRateLimitResetTimestamp(reset);
-                            }
-                        } catch (Throwable t) {
-                            if (restRequestResult.isDone()) {
-                                throw t;
-                            }
-                            restRequestResult.completeExceptionally(t);
-                        }
+                        logger.warn("Encountered unexpected exception.", t);
                     }
-                    if (remove) {
-                        queue.remove(restRequest);
+
+                    // The request didn't finish, so let's try again
+                    if (!currentRequest.getResult().isDone()) {
+                        continue;
+                    }
+
+                    // Poll a new quest
+                    synchronized (buckets) {
+                        bucket.pollRequestFromQueue();
+                        currentRequest = bucket.peekRequestFromQueue();
+                        if (currentRequest == null) {
+                            buckets.remove(bucket);
+                        }
                     }
-                }
-            } catch (Throwable t) {
-                logger.error("Exception in RatelimitManager! Please contact the developer!", t);
-            } finally {
-                synchronized (bucket) {
-                    bucket.setHasActiveScheduler(false);
                 }
             }
-        }), delay, TimeUnit.MILLISECONDS);
+        });
+    }
+
+    /**
+     * Updates the ratelimit information and sets the result if the request was successful.
+     *
+     * @param request The request.
+     * @param result The result of the request.
+     * @param bucket The bucket the request belongs to.
+     * @param responseTimestamp The timestamp directly after the response finished.
+     */
+    private void handleResponse(
+            RestRequest<?> request, RestRequestResult result, RatelimitBucket bucket, long responseTimestamp) {
+        if (result == null) {
+            return;
+        }
+        Response response = result.getResponse();
+        boolean global = response.header("X-RateLimit-Global", "false").equalsIgnoreCase("true");
+        int remaining = Integer.valueOf(response.header("X-RateLimit-Remaining", "1"));
+        long reset = request
+                .getEndpoint()
+                .getHardcodedRatelimit()
+                .map(ratelimit -> responseTimestamp + api.getTimeOffset() + ratelimit)
+                .orElseGet(() -> Long.parseLong(response.header("X-RateLimit-Reset", "0")) * 1000);
+
+        // Check if we received a 429 response
+        if (result.getResponse().code() == 429) {
+            int retryAfter =
+                    result.getJsonBody().isNull() ? 0 : result.getJsonBody().get("retry_after").asInt();
+
+            if (global) {
+                // We hit a global ratelimit. Time to panic!
+                logger.warn("Hit a global ratelimit! This means you were sending a very large "
+                        + "amount within a very short time frame.");
+                RatelimitBucket.setGlobalRatelimitResetTimestamp(api, responseTimestamp + retryAfter);
+            } else {
+                logger.debug("Received a 429 response from Discord! Recalculating time offset...");
+                // Setting the offset to null causes a recalculate for the next request
+                api.setTimeOffset(null);
+
+                // Update the bucket information
+                bucket.setRatelimitRemaining(0);
+                bucket.setRatelimitResetTimestamp(responseTimestamp + retryAfter);
+            }
+        } else {
+            // Check if we didn't already complete it exceptionally.
+            CompletableFuture<RestRequestResult> requestResult = request.getResult();
+            if (!requestResult.isDone()) {
+                requestResult.complete(result);
+            }
+
+            // Update bucket information
+            bucket.setRatelimitRemaining(remaining);
+            bucket.setRatelimitResetTimestamp(reset);
+        }
     }
 
     /**
@@ -226,19 +210,23 @@ public class RatelimitManager implements Cleanupable {
      * @param result The result of the rest request.
      */
     private void calculateOffset(long currentTime, RestRequestResult result) {
-        // Discord sends the date in their header in the format RFC_1123_DATE_TIME
-        // We use this header to calculate a possible offset between our local time and the discord time
-        String date = result.getResponse().header("Date");
-        if (date != null) {
-            long discordTimestamp = OffsetDateTime.parse(date, DateTimeFormatter.RFC_1123_DATE_TIME)
-                    .toInstant().toEpochMilli();
-            api.setTimeOffset((discordTimestamp - currentTime));
-            logger.debug("Calculated an offset of {} to the Discord time.", api::getTimeOffset);
+        // Double-checked locking for better performance
+        if (api.getTimeOffset() != null) {
+            return;
+        }
+        synchronized (api) {
+            if (api.getTimeOffset() == null) {
+                // Discord sends the date in their header in the format RFC_1123_DATE_TIME
+                // We use this header to calculate a possible offset between our local time and the discord time
+                String date = result.getResponse().header("Date");
+                if (date != null) {
+                    long discordTimestamp = OffsetDateTime.parse(date, DateTimeFormatter.RFC_1123_DATE_TIME)
+                            .toInstant().toEpochMilli();
+                    api.setTimeOffset((discordTimestamp - currentTime));
+                    logger.debug("Calculated an offset of {} to the Discord time.", api::getTimeOffset);
+                }
+            }
         }
     }
 
-    @Override
-    public void cleanup() {
-        scheduler.shutdown();
-    }
 }
diff --git a/javacord-core/src/main/java/org/javacord/core/util/rest/RestRequest.java b/javacord-core/src/main/java/org/javacord/core/util/rest/RestRequest.java
index 8b9ef667..0f9a5622 100644
--- a/javacord-core/src/main/java/org/javacord/core/util/rest/RestRequest.java
+++ b/javacord-core/src/main/java/org/javacord/core/util/rest/RestRequest.java
@@ -21,7 +21,6 @@ import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Function;
 
 /**
@@ -39,14 +38,11 @@ public class RestRequest<T> {
     private final RestEndpoint endpoint;
 
     private volatile boolean includeAuthorizationHeader = true;
-    private volatile int ratelimitRetries = 50;
     private volatile String[] urlParameters = new String[0];
     private final Map<String, String> queryParameters = new HashMap<>();
     private final Map<String, String> headers = new HashMap<>();
     private volatile String body = null;
 
-    private final AtomicInteger retryCounter = new AtomicInteger();
-
     private final CompletableFuture<RestRequestResult> result = new CompletableFuture<>();
 
     /**
@@ -213,20 +209,6 @@ public class RestRequest<T> {
         return this;
     }
 
-    /**
-     * Sets the amount of ratelimit retries we should use with this request.
-     *
-     * @param retries The amount of ratelimit retries.
-     * @return The current instance in order to chain call methods.
-     */
-    public RestRequest<T> setRatelimitRetries(int retries) {
-        if (retries < 0) {
-            throw new IllegalArgumentException("Retries cannot be less than 0!");
-        }
-        this.ratelimitRetries = retries;
-        return this;
-    }
-
     /**
      * Sets a custom major parameter.
      *
@@ -270,15 +252,6 @@ public class RestRequest<T> {
         return this;
     }
 
-    /**
-     * Increments the amounts of ratelimit retries.
-     *
-     * @return <code>true</code> if the maximum ratelimit retries were exceeded.
-     */
-    public boolean incrementRetryCounter() {
-        return retryCounter.incrementAndGet() > ratelimitRetries;
-    }
-
     /**
      * Executes the request. This will automatically retry if we hit a ratelimit.
      *