RequestQueue | Easily handle rate limits using Promises

Background Information | What are rate limits?

The most common posts relating to rate limits you see here on DevForum are specifically about DataStores.

image

This can be frustrating and honestly annoying. For example when a player leaves my game I want to make sure their data is saved. But what if I get rate limited because I’ve exceeded the number of requests within the past minute to the DataStore API? Well there’s really no easy way to handle this.

I made this module to solve a similar problem relating to using MarketplaceService’s :GetProductInfo(). As it turns out this function is rate limited to 100 requests per minute. I was requesting 120 times within the same minute. That led to the creation of this module.

Basic Usage

Here’s example usage showing how I solved my own problem using this module:

local HTTP = game:GetService("HttpService")

local RequestQueue = require(script.RequestQueue)

local catalogData = HTTP:GetAsync("https://catalog.rprxy.xyz/v1/search/items?category=Accessories&subcategory=HairAccessories&creatortargetid=1&limit=60")
catalogData = HTTP:JSONDecode(catalogData)

-- Create a new queue
--- param1: Should debugging be enabled?
--- param2: What if a request gets rejected? How should it be handled?
----- This examples uses RETRY which will requeue a request after a certain period of time
--- param3: How many seconds before requeuing the rejected request?
local queue = RequestQueue.new(false, RequestQueue.RejectionHandling.RETRY, 60)

-- Called once the queue has resolved all requests
queue:resolved(function(data)
	print(data) -- Successfully prints all the data from :GetProductInfo()!
end)

-- catalogData.data contains 60 entries
for _, v in pairs(catalogData.data) do
    -- Enqueue a request and pass in a handler function as well as some parameters
    -- The returned value is added to the finalized data table in the :resolved() method
	queue:enqueue(function(id, infoType)
		return game:GetService('MarketplaceService'):GetProductInfo(id, infoType)
	end, v.id, Enum.InfoType.Asset)
end

This example on its own isn’t actually rate limited. However, if I was to press Run → Stop → Run then you would see rate limits were enforced and the data isn’t printed until 60 seconds after the 2nd Run since the retry time is set to 60 seconds.

API

RequestQueue.new(debugEnabled: boolean?, rejectionHandling: number?, retryRate: number?, maxRetries: number?) -> Queue

Create a new Queue object with the provided settings.

Queue:enqueue(handler: function(...: any?) -> any?, ...: any?) -> Promise

Enqueue a request that will return a Promise.

Queue:deferredEnqueue(seconds: number, handler: function(...: any?) -> any?, ...: any?) -> void

Defer a request then enqueue after the specified amount of time.

Queue:resolved(callback: function(resolvedRequests: { any }) -> void) -> void

Hook a callback function to be executed after all requests in the queue are either resolved or rejected (depending on set RejectionHandling).

RejectionHandling

RejectionHandling.FAIL: Cause an error if a request fails

RejectionHandling.WARN: Create a warning in the output if a request fails

RejectionHandling.RETRY: Retry a request after the specified amount of time

Promises

For more information on the promises used within this module see here.

Download

Source
--[[
	
	RequestQueue by AstrealDev
	Easily handle rate limits
	
	------------------------------------------------------------------------
	
	Example:
	local HTTP = game:GetService("HttpService")

	local RequestQueue = require(script.RequestQueue)

	local catalogData = HTTP:GetAsync("https://catalog.rprxy.xyz/v1/search/items?category=Accessories&subcategory=HairAccessories&creatortargetid=1&limit=60")
	catalogData = HTTP:JSONDecode(catalogData)

	-- :GetProductInfo() has a rate limit of 100 requests per minute so retrying every 60 seconds
	-- will fix this issue
	local queue = RequestQueue.new(true, RequestQueue.RejectionHandling.RETRY, 60)

	-- Called upon a queue being fully resolved
	queue:resolved(function(data)
		print(data)
	end)

	for _, v in pairs(catalogData.data) do
		queue:enqueue(function(id, infoType)
			return game:GetService('MarketplaceService'):GetProductInfo(id, infoType)
		end, v.id, Enum.InfoType.Asset)
	end

]]

local Promise = require(script.Promise)
local RequestQueue = {
	RejectionHandling = {
		FAIL = 0,	-- FAIL:	Will error if an enqueue fails
		WARN = 1,	-- WARN:	Will warn if an enqueue fails
		RETRY = 2	-- RETRY:	Will attempt to retry an enqueue by deferring it based on provided retryRate
	},
}
local QueueClass = {}

--[[
	Get a new Queue object
]]
function RequestQueue.new(debugEnabled: boolean?, rejectionHandling: number?, retryRate: number?, maxRetries: number?)
	local self = setmetatable({}, {__index = QueueClass})
	
	self._rejectionHandling = rejectionHandling
		and (rejectionHandling >= 3 or rejectionHandling < 0)
			and 0
			or rejectionHandling
		or RequestQueue.RejectionHandling.FAIL
	self._queued = 0
	self._resolved = {}
	self._debug = debugEnabled or false
	self._retries = 0
	self._maxRetries = maxRetries or math.huge
	self._retryRate = (self._rejectionHandling == RequestQueue.RejectionHandling.RETRY) and (retryRate or 15) or 15
	
	return self
end

--[[
	Enqueue a request
]]
function QueueClass:enqueue(handler, ...)
	local params = {...}
	
	self._queued += 1
	
	local result
	local promise = Promise.new(function(resolve, reject)
		local success, err = pcall(function()
			result = handler(unpack(params))
		end)
		if (success) then
			resolve(result)
		else
			reject(err)
		end
	end)
	
	promise:andThen(function(res)
		if (self._debug) then print('Enqueue was successful') end
		self._queued -= 1
		
		table.insert(self._resolved, res)
		
		if (self._queued == 0) then
			if (self._onResolution) then
				local returnData = self._resolved
				
				if (#returnData == 1) then
					returnData = returnData[1]
				elseif (returnData == 0) then
					returnData = nil
				end
				
				self._onResolution(returnData)
			end
		end
	end):catch(function(err)
		if (self._rejectionHandling == RequestQueue.RejectionHandling.FAIL) then 
			error(err)
		elseif (self._rejectionHandling == RequestQueue.RejectionHandling.WARN or self._debug) then
			warn('Enqueue failed | ' .. err)
		end
		
		self._queued -= 1
		
		if (self._rejectionHandling == RequestQueue.RejectionHandling.RETRY) then
			if (self._retries > self._maxRetries) then
				warn(string.format('Enqueue has failed over %d times and will potentially never resolve.', self._maxRetries))
			end
			
			self:deferredEnqueue(self._retryRate, handler, unpack(params))
			
			self._retries += 1
		end
	end)
	
	return promise
end

--[[
	Defer a request then enqueue it
]]
function QueueClass:deferredEnqueue(seconds: number, handler, ...)
	local params = {...}
	
	Promise.delay(seconds):andThen(function()
		self:enqueue(handler, unpack(params))
	end)
end

--[[
	Hook a callback to be called whenever all requests in the queue are fully resolved
]]
function QueueClass:resolved(callback: (resolvedRequests: { any }) -> void)
	self._onResolution = callback or (function() end)
end

return RequestQueue
18 Likes

Hey,
thanks for the neat module, I have a question though. Can I possibly get rid of DataStore rate limit warnings with this?

(Sorry it might sound like a dumb question, but I wanted to ask anyway :D)