@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3abstract class PhabricatorClientLimit {
4
5 private $limitKey;
6 private $clientKey;
7 private $limit;
8
9 final public function setLimitKey($limit_key) {
10 $this->limitKey = $limit_key;
11 return $this;
12 }
13
14 final public function getLimitKey() {
15 return $this->limitKey;
16 }
17
18 final public function setClientKey($client_key) {
19 $this->clientKey = $client_key;
20 return $this;
21 }
22
23 final public function getClientKey() {
24 return $this->clientKey;
25 }
26
27 final public function setLimit($limit) {
28 $this->limit = $limit;
29 return $this;
30 }
31
32 final public function getLimit() {
33 return $this->limit;
34 }
35
36 final public function didConnect() {
37 // NOTE: We can not use pht() here because this runs before libraries
38 // load.
39
40 if (!function_exists('apcu_fetch')) {
41 throw new Exception(
42 'You can not configure connection rate limits unless APCu is '.
43 'available. Rate limits rely on APCu to track clients and '.
44 'connections.');
45 }
46
47 if ($this->getClientKey() === null) {
48 throw new Exception(
49 'You must configure a client key when defining a rate limit.');
50 }
51
52 if ($this->getLimitKey() === null) {
53 throw new Exception(
54 'You must configure a limit key when defining a rate limit.');
55 }
56
57 if ($this->getLimit() === null) {
58 throw new Exception(
59 'You must configure a limit when defining a rate limit.');
60 }
61
62 $points = $this->getConnectScore();
63 if ($points) {
64 $this->addScore($points);
65 }
66
67 $score = $this->getScore();
68 if (!$this->shouldRejectConnection($score)) {
69 // Client has not hit the limit, so continue processing the request.
70 return null;
71 }
72
73 $penalty = $this->getPenaltyScore();
74 if ($penalty) {
75 $this->addScore($penalty);
76 $score += $penalty;
77 }
78
79 return $this->getRateLimitReason($score);
80 }
81
82 final public function didDisconnect(array $request_state) {
83 $score = $this->getDisconnectScore($request_state);
84 if ($score) {
85 $this->addScore($score);
86 }
87 }
88
89
90 /**
91 * Get the number of seconds for each rate bucket.
92 *
93 * For example, a value of 60 will create one-minute buckets.
94 *
95 * @return int Number of seconds per bucket.
96 */
97 abstract protected function getBucketDuration();
98
99
100 /**
101 * Get the total number of rate limit buckets to retain.
102 *
103 * @return int Total number of rate limit buckets to retain.
104 */
105 abstract protected function getBucketCount();
106
107
108 /**
109 * Get the score to add when a client connects.
110 *
111 * @return double Connection score.
112 */
113 abstract protected function getConnectScore();
114
115
116 /**
117 * Get the number of penalty points to add when a client hits a rate limit.
118 *
119 * @return double Penalty score.
120 */
121 abstract protected function getPenaltyScore();
122
123
124 /**
125 * Get the score to add when a client disconnects.
126 *
127 * @return double Connection score.
128 */
129 abstract protected function getDisconnectScore(array $request_state);
130
131
132 /**
133 * Get a human-readable explanation of why the client is being rejected.
134 *
135 * @return string Brief rejection message.
136 */
137 abstract protected function getRateLimitReason($score);
138
139
140 /**
141 * Determine whether to reject a connection.
142 *
143 * @return bool True to reject the connection.
144 */
145 abstract protected function shouldRejectConnection($score);
146
147
148 /**
149 * Get the APC key for the smallest stored bucket.
150 *
151 * @return string APC key for the smallest stored bucket.
152 * @task ratelimit
153 */
154 private function getMinimumBucketCacheKey() {
155 $limit_key = $this->getLimitKey();
156 return "limit:min:{$limit_key}";
157 }
158
159
160 /**
161 * Get the current bucket ID for storing rate limit scores.
162 *
163 * @return int The current bucket ID.
164 */
165 private function getCurrentBucketID() {
166 return (int)(time() / $this->getBucketDuration());
167 }
168
169
170 /**
171 * Get the APC key for a given bucket.
172 *
173 * @param int $bucket_id Bucket to get the key for.
174 * @return string APC key for the bucket.
175 */
176 private function getBucketCacheKey($bucket_id) {
177 $limit_key = $this->getLimitKey();
178 return "limit:bucket:{$limit_key}:{$bucket_id}";
179 }
180
181
182 /**
183 * Add points to the rate limit score for some client.
184 *
185 * @param float $score The cost for this request; more points pushes them
186 * toward the limit faster.
187 * @return $this
188 */
189 private function addScore($score) {
190 $is_apcu = (bool)function_exists('apcu_fetch');
191
192 $current = $this->getCurrentBucketID();
193 $bucket_key = $this->getBucketCacheKey($current);
194
195 // There's a bit of a race here, if a second process reads the bucket
196 // before this one writes it, but it's fine if we occasionally fail to
197 // record a client's score. If they're making requests fast enough to hit
198 // rate limiting, we'll get them soon enough.
199
200 if ($is_apcu) {
201 $bucket = apcu_fetch($bucket_key);
202 }
203
204 if (!isset($bucket) || !is_array($bucket)) {
205 $bucket = array();
206 }
207
208 $client_key = $this->getClientKey();
209 if (empty($bucket[$client_key])) {
210 $bucket[$client_key] = 0;
211 }
212
213 $bucket[$client_key] += $score;
214
215 if ($is_apcu) {
216 @apcu_store($bucket_key, $bucket);
217 }
218
219 return $this;
220 }
221
222
223 /**
224 * Get the current rate limit score for a given client.
225 *
226 * @return float The client's current score.
227 * @task ratelimit
228 */
229 private function getScore() {
230 $is_apcu = (bool)function_exists('apcu_fetch');
231
232 // Identify the oldest bucket stored in APC.
233 $min_key = $this->getMinimumBucketCacheKey();
234 if ($is_apcu) {
235 $min = apcu_fetch($min_key);
236 }
237
238 // If we don't have any buckets stored yet, store the current bucket as
239 // the oldest bucket.
240 $cur = $this->getCurrentBucketID();
241 if (!isset($min) || !$min) {
242 if ($is_apcu) {
243 @apcu_store($min_key, $cur);
244 }
245 $min = $cur;
246 }
247
248 // Destroy any buckets that are older than the minimum bucket we're keeping
249 // track of. Under load this normally shouldn't do anything, but will clean
250 // up an old bucket once per minute.
251 $count = $this->getBucketCount();
252 for ($cursor = $min; $cursor < ($cur - $count); $cursor++) {
253 $bucket_key = $this->getBucketCacheKey($cursor);
254 if ($is_apcu) {
255 apcu_delete($bucket_key);
256 @apcu_store($min_key, $cursor + 1);
257 }
258 }
259
260 $client_key = $this->getClientKey();
261
262 // Now, sum up the client's scores in all of the active buckets.
263 $score = 0;
264 for (; $cursor <= $cur; $cursor++) {
265 $bucket_key = $this->getBucketCacheKey($cursor);
266 if ($is_apcu) {
267 $bucket = apcu_fetch($bucket_key);
268 }
269 if (isset($bucket[$client_key])) {
270 $score += $bucket[$client_key];
271 }
272 }
273
274 return $score;
275 }
276
277}