1 /**
2   Utilities for tracking and managing rate limits.
3 */
4 module dcord.api.ratelimit;
5 
6 import std.conv,
7        std.math,
8        std.random,
9        core.time,
10        core.sync.mutex;
11 
12 import vibe.core.core;
13 import vibe.core.sync : LocalManualEvent, createManualEvent;
14 
15 import dcord.api.routes,
16        dcord.util.time;
17 
18 /// Return a random backoff duration (by default between 0.5 and 3 seconds)
19 Duration randomBackoff(int low=500, int high=3000) {
20   int milliseconds = uniform(low, high);
21   return milliseconds.msecs;
22 }
23 
24 /**
25   Stores the rate limit state for a given bucket.
26 */
27 struct RateLimitState {
28   int remaining;
29 
30   /// Time at which this rate limit resets
31   long resetTime;
32 
33   /// Returns true if this request is valid
34   bool willRateLimit() {
35     if(this.remaining - 1 < 0) 
36       if(getEpochTime() <= this.resetTime) return true; 
37 
38     return false;
39   }
40 
41   /// Return the time that needs to be waited before another request can be made
42   Duration waitTime() {
43     return (this.resetTime - getEpochTime()).seconds + 500.msecs;
44   }
45 }
46 
47 /**
48   RateLimiter provides an interface for rate limiting HTTP Requests.
49 */
50 class RateLimiter {
51   LocalManualEvent[Bucket] cooldowns;
52   RateLimitState[Bucket] states;
53 
54   /// Cooldown a bucket for a given duration. Blocks ALL requests from completing.
55   void cooldown(Bucket bucket, Duration duration) {
56     if(bucket in this.cooldowns) {
57       this.cooldowns[bucket].wait();
58     } else {
59       this.cooldowns[bucket] = createManualEvent();
60       sleep(duration);
61       this.cooldowns[bucket].emit();
62       this.cooldowns.remove(bucket);
63     }
64   }
65 
66   /**
67     Check whether a request can be made for a bucket. If the bucket is on cooldown,
68     wait until the cooldown resets before returning.
69   */
70   bool check(Bucket bucket, Duration timeout) {
71     // If we're currently waiting for a cooldown, join the waiting club
72     if (bucket in this.cooldowns) {
73       if (this.cooldowns[bucket].wait(timeout, 0) != 0) {
74         return false;
75       }
76     }
77 
78     // If we don't have the bucket cached, return
79     if (bucket !in this.states) return true;
80 
81     // If this request will rate limit, wait until it won't anymore
82     if(this.states[bucket].willRateLimit()) 
83       this.cooldown(bucket, this.states[bucket].waitTime());
84 
85     return true;
86   }
87 
88   /// Update a given bucket with headers returned from a request.
89   void update(Bucket bucket, string remaining, string reset, string retryAfter) {
90     long resetSeconds = (reset.to!long);
91 
92     // If we have a retryAfter header, it may be more accurate
93     if(retryAfter != "") {
94       FloatingPointControl fpctrl;
95       fpctrl.rounding = FloatingPointControl.roundUp;
96       long retryAfterSeconds = rndtol(retryAfter.to!long / 1000.0);
97       long nextRequestAt = getEpochTime() + retryAfterSeconds;
98       if(nextRequestAt > resetSeconds) resetSeconds = nextRequestAt;
99     }
100 
101     // Create a new RateLimitState if one doesn't exist
102     if(bucket !in this.states) 
103       this.states[bucket] = RateLimitState();
104     
105     // Save our remaining requests and reset seconds
106     this.states[bucket].remaining = remaining.to!int;
107     this.states[bucket].resetTime = resetSeconds;
108   }
109 }
110