Skip to content

Commit 34fc7c2

Browse files
committed
Add a rate limiter that counts all requests sent
1 parent bbb4c08 commit 34fc7c2

File tree

1 file changed

+150
-0
lines changed

1 file changed

+150
-0
lines changed

Runtime/Client/LootLockerServerRequest.cs

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,141 @@ public class LootLockerResponseFactory
150150
{
151151
return Error<T>("Method parameter could not be serialized");
152152
}
153+
154+
/// <summary>
155+
/// Construct an error response because an rate limit has been hit
156+
/// </summary>
157+
public static T RateLimitExceeded<T>(string method, int secondsLeftOfRateLimit) where T : LootLockerResponse, new()
158+
{
159+
return Error<T>(string.Format("You are sending too many requests and are being rate limited for %d seconds. Call was to endpoint %s", secondsLeftOfRateLimit, method));
160+
}
161+
}
162+
163+
164+
165+
#region Rate Limiting Support
166+
167+
public class RateLimiter
168+
{
169+
/* -- Configurable constants -- */
170+
// Tripwire settings, allow for a max total of n requests per x seconds
171+
protected const int TripWireTimeFrameSeconds = 60;
172+
protected const int MaxRequestsPerTripWireTimeFrame = 280; // The backend rate limit is 300 requests per minute so we'll limit it slightly before that
173+
protected const int SecondsPerBucket = 5; // Needs to evenly divide the time frame
174+
175+
// Moving average settings, allow for a max average of n requests per x seconds
176+
protected const float AllowXPercentOfTripWireMaxForMovingAverage = 0.8f; // Moving average threshold (the average number of requests per bucket) is set slightly lower to stop constant abusive call behaviour just under the tripwire limit
177+
protected const int CountMovingAverageAcrossNTripWireTimeFrames = 3; // Count Moving average across a longer time period
178+
179+
/* -- Calculated constants -- */
180+
protected const int BucketsPerTimeFrame = TripWireTimeFrameSeconds / SecondsPerBucket;
181+
protected const int RateLimitMovingAverageBucketCount = CountMovingAverageAcrossNTripWireTimeFrames * BucketsPerTimeFrame;
182+
private const int MaxRequestsPerBucketOnMovingAverage = (int)((MaxRequestsPerTripWireTimeFrame * AllowXPercentOfTripWireMaxForMovingAverage) / (BucketsPerTimeFrame));
183+
184+
185+
/* -- Functionality -- */
186+
protected readonly int[] buckets = new int[RateLimitMovingAverageBucketCount];
187+
188+
protected int lastBucket = -1;
189+
private DateTime _lastBucketChangeTime = DateTime.MinValue;
190+
private int _totalRequestsInBuckets;
191+
private int _totalRequestsInBucketsInTripWireTimeFrame;
192+
193+
protected bool isRateLimited = false;
194+
private DateTime _rateLimitResolvesAt = DateTime.MinValue;
195+
196+
protected virtual DateTime GetTimeNow()
197+
{
198+
return DateTime.Now;
199+
}
200+
201+
public int GetSecondsLeftOfRateLimit()
202+
{
203+
if (!isRateLimited)
204+
{
205+
return 0;
206+
}
207+
return (int)Math.Ceiling((_rateLimitResolvesAt - GetTimeNow()).TotalSeconds);
208+
}
209+
private int MoveCurrentBucket(DateTime now)
210+
{
211+
int moveOverXBuckets = _lastBucketChangeTime == DateTime.MinValue ? 1 : (int)Math.Floor((now - _lastBucketChangeTime).TotalSeconds / SecondsPerBucket);
212+
if (moveOverXBuckets == 0)
213+
{
214+
return lastBucket;
215+
}
216+
217+
for (int stepIndex = 1; stepIndex <= moveOverXBuckets; stepIndex++)
218+
{
219+
int bucketIndex = (lastBucket + stepIndex) % buckets.Length;
220+
if (bucketIndex == lastBucket)
221+
{
222+
continue;
223+
}
224+
int bucketMovingOutOfTripWireTimeFrame = (bucketIndex - BucketsPerTimeFrame) < 0 ? buckets.Length + (bucketIndex - BucketsPerTimeFrame) : bucketIndex - BucketsPerTimeFrame;
225+
_totalRequestsInBucketsInTripWireTimeFrame -= buckets[bucketMovingOutOfTripWireTimeFrame]; // Remove the request count from the bucket that is moving out of the time frame from trip wire count
226+
_totalRequestsInBuckets -= buckets[bucketIndex]; // Remove the count from the bucket we're moving into from the total before emptying it
227+
buckets[bucketIndex] = 0;
228+
}
229+
230+
return (lastBucket + moveOverXBuckets) % buckets.Length; // Step to next bucket and wrap around if necessary;
231+
}
232+
233+
public virtual bool AddRequestAndCheckIfRateLimitHit()
234+
{
235+
DateTime now = GetTimeNow();
236+
var currentBucket = MoveCurrentBucket(now);
237+
238+
if (isRateLimited)
239+
{
240+
if (_totalRequestsInBuckets <= 0)
241+
{
242+
isRateLimited = false;
243+
_rateLimitResolvesAt = DateTime.MinValue;
244+
}
245+
}
246+
else
247+
{
248+
buckets[currentBucket]++; // Increment the current bucket
249+
_totalRequestsInBuckets++; // Increment the total request count
250+
_totalRequestsInBucketsInTripWireTimeFrame++; // Increment the request count for the current time frame
251+
252+
isRateLimited |= _totalRequestsInBucketsInTripWireTimeFrame >= MaxRequestsPerTripWireTimeFrame; // If the request count for the time frame is greater than the max requests per time frame, set isRateLimited to true
253+
isRateLimited |= _totalRequestsInBuckets / RateLimitMovingAverageBucketCount > MaxRequestsPerBucketOnMovingAverage; // If the average number of requests per bucket is greater than the max requests on moving average, set isRateLimited to true
254+
#if UNITY_EDITOR
255+
if (_totalRequestsInBucketsInTripWireTimeFrame >= MaxRequestsPerTripWireTimeFrame) Debug.Log("Rate Limit Hit due to Trip Wire, count = " + _totalRequestsInBucketsInTripWireTimeFrame + " out of allowed " + MaxRequestsPerTripWireTimeFrame);
256+
if (_totalRequestsInBuckets / RateLimitMovingAverageBucketCount > MaxRequestsPerBucketOnMovingAverage) Debug.Log("Rate Limit Hit due to Moving Average, count = " + _totalRequestsInBuckets / RateLimitMovingAverageBucketCount + " out of allowed " + MaxRequestsPerBucketOnMovingAverage);
257+
#endif
258+
if (isRateLimited)
259+
{
260+
_rateLimitResolvesAt = (now - TimeSpan.FromSeconds(now.Second % SecondsPerBucket)) + TimeSpan.FromSeconds(buckets.Length*SecondsPerBucket);
261+
}
262+
}
263+
if (currentBucket != lastBucket)
264+
{
265+
_lastBucketChangeTime = now;
266+
lastBucket = currentBucket;
267+
}
268+
return isRateLimited;
269+
}
270+
271+
protected int GetMaxRequestsInSingleBucket()
272+
{
273+
int maxRequests = 0;
274+
foreach (var t in buckets)
275+
{
276+
maxRequests = Math.Max(maxRequests, t);
277+
}
278+
279+
return maxRequests;
280+
}
281+
282+
private static readonly RateLimiter _rateLimiter = new RateLimiter();
283+
public static RateLimiter Get() { return _rateLimiter; }
153284
}
154285

286+
#endregion
287+
155288
/// <summary>
156289
/// Construct a request to send to the server.
157290
/// </summary>
@@ -185,6 +318,12 @@ public struct LootLockerServerRequest
185318

186319
public static void CallAPI(string endPoint, LootLockerHTTPMethod httpMethod, string body = null, Action<LootLockerResponse> onComplete = null, bool useAuthToken = true, LootLocker.LootLockerEnums.LootLockerCallerRole callerRole = LootLocker.LootLockerEnums.LootLockerCallerRole.User)
187320
{
321+
if (RateLimiter.Get().AddRequestAndCheckIfRateLimitHit())
322+
{
323+
onComplete?.Invoke(LootLockerResponseFactory.RateLimitExceeded<LootLockerResponse>(endPoint, RateLimiter.Get().GetSecondsLeftOfRateLimit()));
324+
return;
325+
}
326+
188327
#if UNITY_EDITOR
189328
LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Verbose)("Caller Type: " + callerRole);
190329
#endif
@@ -214,6 +353,12 @@ public static void CallAPI(string endPoint, LootLockerHTTPMethod httpMethod, str
214353

215354
public static void CallDomainAuthAPI(string endPoint, LootLockerHTTPMethod httpMethod, string body = null, Action<LootLockerResponse> onComplete = null)
216355
{
356+
if (RateLimiter.Get().AddRequestAndCheckIfRateLimitHit())
357+
{
358+
onComplete?.Invoke(LootLockerResponseFactory.RateLimitExceeded<LootLockerResponse>(endPoint, RateLimiter.Get().GetSecondsLeftOfRateLimit()));
359+
return;
360+
}
361+
217362
if (LootLockerConfig.current.domainKey.ToString().Length == 0)
218363
{
219364
#if UNITY_EDITOR
@@ -239,6 +384,11 @@ public static void CallDomainAuthAPI(string endPoint, LootLockerHTTPMethod httpM
239384

240385
public static void UploadFile(string endPoint, LootLockerHTTPMethod httpMethod, byte[] file, string fileName = "file", string fileContentType = "text/plain", Dictionary<string, string> body = null, Action<LootLockerResponse> onComplete = null, bool useAuthToken = true, LootLocker.LootLockerEnums.LootLockerCallerRole callerRole = LootLocker.LootLockerEnums.LootLockerCallerRole.User)
241386
{
387+
if (RateLimiter.Get().AddRequestAndCheckIfRateLimitHit())
388+
{
389+
onComplete?.Invoke(LootLockerResponseFactory.RateLimitExceeded<LootLockerResponse>(endPoint, RateLimiter.Get().GetSecondsLeftOfRateLimit()));
390+
return;
391+
}
242392
Dictionary<string, string> headers = new Dictionary<string, string>();
243393

244394
if (useAuthToken)

0 commit comments

Comments
 (0)