PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-rate-limiter.php +56 -150 2.6.5 → 2.7.1 View file →
@@ -1,13 +1,12 @@
1 1 <?php
2 2 /**
3 3 * MetaSync Rate Limiter
4 4 *
5 - * A robust rate limiting implementation that uses direct database storage
6 - * to ensure consistent behavior across all server configurations including:
7 - * - Object caching (Redis, Memcached)
8 - * - Load-balanced environments
9 - * - Server-side page caching
5 + * A robust rate limiting implementation that uses per-key WordPress
6 + * transients so each IP/token hash is stored as its own row with a
7 + * TTL matching the rate-limit window. This avoids the contention,
8 + * race conditions, and unbounded growth of a single serialized blob.
10 9 *
11 10 * @package Metasync
12 11 * @subpackage Metasync/includes
13 12 * @since 2.5.17
@@ -20,11 +19,11 @@
20 19
21 20 class Metasync_Rate_Limiter
22 21 {
23 22 /**
24 - * Option name for storing rate limit data
23 + * Prefix for per-key rate limit transients.
25 24 */
26 - const OPTION_NAME = 'metasync_rate_limits';
25 + const TRANSIENT_PREFIX = 'metasync_rl_';
27 26
28 27 /**
29 28 * Singleton instance
30 29 *
@@ -49,53 +48,63 @@
49 48 * Private constructor for singleton pattern
50 49 */
51 50 private function __construct()
52 51 {
53 - # Schedule cleanup if not already scheduled
54 - if (!wp_next_scheduled('metasync_rate_limit_cleanup')) {
55 - wp_schedule_event(time(), 'hourly', 'metasync_rate_limit_cleanup');
56 - }
52 + # Cancel any previously scheduled hourly cleanup job — transient
53 + # expiry handles cleanup automatically now.
54 + wp_clear_scheduled_hook('metasync_rate_limit_cleanup');
57 55 }
58 56
59 57 /**
60 - * Check and increment rate limit for a given key
58 + * Build the transient key for a given full rate-limit key.
61 59 *
62 - * Uses atomic database operations to ensure consistency across
63 - * all server configurations including object caching and load balancers.
60 + * Hashing keeps the resulting transient name comfortably under
61 + * WordPress's 172-char limit regardless of the input length.
64 62 *
65 - * @param string $key Unique identifier for rate limiting (e.g., hashed token or IP)
66 - * @param int $max_attempts Maximum number of attempts allowed
63 + * @param string $full_key Full rate-limit key (prefix + key).
64 + * @return string Transient name.
65 + */
66 + private function get_transient_key($full_key)
67 + {
68 + return self::TRANSIENT_PREFIX . substr(hash('sha256', $full_key), 0, 40);
69 + }
70 +
71 + /**
72 + * Check and increment rate limit for a given key.
73 + *
74 + * Each key is tracked in its own transient, so concurrent
75 + * requests for different keys never share a write lock.
76 + *
77 + * @param string $key Unique identifier for rate limiting (e.g., hashed token or IP)
78 + * @param int $max_attempts Maximum number of attempts allowed
67 79 * @param int $window_seconds Time window in seconds
68 - * @param string $prefix Optional prefix for the rate limit key
80 + * @param string $prefix Optional prefix for the rate limit key
69 81 * @return bool|WP_Error True if under limit, WP_Error if rate limit exceeded
70 82 */
71 83 public function check_rate_limit($key, $max_attempts, $window_seconds, $prefix = '')
72 84 {
73 - global $wpdb;
85 + $full_key = $prefix . $key;
86 + $transient_key = $this->get_transient_key($full_key);
87 + $now = time();
74 88
75 - $full_key = $prefix . $key;
76 - $now = time();
89 + $data = get_transient($transient_key);
77 90
78 - # Get current rate limit data with row-level locking for atomicity
79 - # Using direct database query instead of get_option for reliability
80 - $data = $this->get_rate_limit_data();
81 -
82 - # Initialize entry if not exists or expired
83 - if (!isset($data[$full_key]) || $data[$full_key]['expires_at'] < $now) {
84 - $data[$full_key] = array(
85 - 'attempts' => 1,
91 + # Initialize entry if not present or expired
92 + if ($data === false || !is_array($data) || !isset($data['expires_at']) || $data['expires_at'] < $now) {
93 + $data = array(
94 + 'attempts' => 1,
86 95 'first_attempt_at' => $now,
87 - 'expires_at' => $now + $window_seconds,
88 - 'window_seconds' => $window_seconds,
96 + 'expires_at' => $now + $window_seconds,
97 + 'window_seconds' => $window_seconds,
89 98 );
90 - $this->save_rate_limit_data($data);
99 + set_transient($transient_key, $data, $window_seconds);
91 100
92 101 return true;
93 102 }
94 103
95 104 # Check if rate limit exceeded
96 - if ($data[$full_key]['attempts'] >= $max_attempts) {
97 - $remaining_seconds = $data[$full_key]['expires_at'] - $now;
105 + if ($data['attempts'] >= $max_attempts) {
106 + $remaining_seconds = $data['expires_at'] - $now;
98 107 $remaining_minutes = ceil($remaining_seconds / 60);
99 108
100 109 return new WP_Error(
101 110 'rate_limit_exceeded',
@@ -105,18 +114,18 @@
105 114 $remaining_minutes > 1 ? 's' : ''
106 115 ),
107 116 array(
108 117 'retry_after' => $remaining_seconds,
109 - 'attempts' => $data[$full_key]['attempts'],
118 + 'attempts' => $data['attempts'],
110 119 'max_attempts' => $max_attempts,
111 120 )
112 121 );
113 122 }
114 123
115 - # Increment attempt count
116 - $data[$full_key]['attempts']++;
117 - $data[$full_key]['last_attempt_at'] = $now;
118 - $this->save_rate_limit_data($data);
124 + # Increment attempt count, preserving the original window end
125 + $data['attempts']++;
126 + $data['last_attempt_at'] = $now;
127 + set_transient($transient_key, $data, max(1, $data['expires_at'] - $now));
119 128
120 129 return true;
121 130 }
122 131
@@ -172,94 +181,8 @@
172 181 return $this->check_rate_limit($token_hash, $max_attempts, $window_seconds, 'token_');
173 182 }
174 183
175 184 /**
176 - * Get rate limit data from database
177 - *
178 - * Uses direct SQL query to bypass object caching and ensure
179 - * we always get the latest data from the database.
180 - *
181 - * @return array Rate limit data
182 - */
183 - private function get_rate_limit_data()
184 - {
185 - global $wpdb;
186 -
187 - # Bypass object cache by using direct SQL query
188 - $value = $wpdb->get_var(
189 - $wpdb->prepare(
190 - "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
191 - self::OPTION_NAME
192 - )
193 - );
194 -
195 - if ($value === null) {
196 - return array();
197 - }
198 -
199 - $data = maybe_unserialize($value);
200 - return is_array($data) ? $data : array();
201 - }
202 -
203 - /**
204 - * Save rate limit data to database
205 - *
206 - * Uses direct SQL query with ON DUPLICATE KEY UPDATE for atomicity.
207 - *
208 - * @param array $data Rate limit data
209 - * @return bool Success
210 - */
211 - private function save_rate_limit_data($data)
212 - {
213 - global $wpdb;
214 -
215 - $serialized = maybe_serialize($data);
216 -
217 - # Use REPLACE to ensure atomic update (INSERT or UPDATE)
218 - $result = $wpdb->query(
219 - $wpdb->prepare(
220 - "INSERT INTO {$wpdb->options} (option_name, option_value, autoload)
221 - VALUES (%s, %s, 'no')
222 - ON DUPLICATE KEY UPDATE option_value = VALUES(option_value)",
223 - self::OPTION_NAME,
224 - $serialized
225 - )
226 - );
227 -
228 - # Clear any object cache for this option
229 - wp_cache_delete(self::OPTION_NAME, 'options');
230 -
231 - return $result !== false;
232 - }
233 -
234 - /**
235 - * Clean up expired rate limit entries
236 - *
237 - * Called via cron job to prevent database bloat.
238 - *
239 - * @return int Number of entries cleaned up
240 - */
241 - public function cleanup_expired_entries()
242 - {
243 - $data = $this->get_rate_limit_data();
244 - $now = time();
245 - $original_count = count($data);
246 -
247 - # Remove expired entries
248 - $data = array_filter($data, function ($entry) use ($now) {
249 - return isset($entry['expires_at']) && $entry['expires_at'] > $now;
250 - });
251 -
252 - $removed_count = $original_count - count($data);
253 -
254 - if ($removed_count > 0) {
255 - $this->save_rate_limit_data($data);
256 - }
257 -
258 - return $removed_count;
259 - }
260 -
261 - /**
262 185 * Get client IP address
263 186 *
264 187 * Handles various proxy configurations and load balancers.
265 188 *
@@ -304,16 +227,9 @@
304 227 * @return bool Success
305 228 */
306 229 public function reset_rate_limit($key, $prefix = '')
307 230 {
308 - $full_key = $prefix . $key;
309 - $data = $this->get_rate_limit_data();
310 -
311 - if (isset($data[$full_key])) {
312 - unset($data[$full_key]);
313 - return $this->save_rate_limit_data($data);
314 - }
315 -
231 + delete_transient($this->get_transient_key($prefix . $key));
316 232 return true;
317 233 }
318 234
319 235 /**
@@ -324,32 +240,22 @@
324 240 * @return array|null Rate limit status or null if not found
325 241 */
326 242 public function get_rate_limit_status($key, $prefix = '')
327 243 {
328 - $full_key = $prefix . $key;
329 - $data = $this->get_rate_limit_data();
244 + $data = get_transient($this->get_transient_key($prefix . $key));
330 245
331 - if (!isset($data[$full_key])) {
246 + if ($data === false || !is_array($data)) {
332 247 return null;
333 248 }
334 249
335 - $entry = $data[$full_key];
336 250 $now = time();
337 251
338 252 return array(
339 - 'attempts' => $entry['attempts'],
340 - 'expires_at' => $entry['expires_at'],
341 - 'remaining_seconds' => max(0, $entry['expires_at'] - $now),
342 - 'is_expired' => $entry['expires_at'] < $now,
343 - 'first_attempt_at' => $entry['first_attempt_at'],
344 - 'last_attempt_at' => isset($entry['last_attempt_at']) ? $entry['last_attempt_at'] : null,
253 + 'attempts' => $data['attempts'],
254 + 'expires_at' => $data['expires_at'],
255 + 'remaining_seconds' => max(0, $data['expires_at'] - $now),
256 + 'is_expired' => $data['expires_at'] < $now,
257 + 'first_attempt_at' => $data['first_attempt_at'],
258 + 'last_attempt_at' => isset($data['last_attempt_at']) ? $data['last_attempt_at'] : null,
345 259 );
346 260 }
347 261 }
348 -
349 -/**
350 - * Hook for cron cleanup
351 - */
352 -add_action('metasync_rate_limit_cleanup', function () {
353 - $rate_limiter = Metasync_Rate_Limiter::get_instance();
354 - $rate_limiter->cleanup_expired_entries();
355 -});