324 lines
9.2 KiB
PHP
Executable File
324 lines
9.2 KiB
PHP
Executable File
<?php
|
|
/**
|
|
* Rate limiter class for MLS Grid API compliance
|
|
*
|
|
* MLS Grid Rate Limits:
|
|
* - 2 requests per second
|
|
* - 7,200 requests per hour
|
|
* - 40,000 requests per day
|
|
* - 4GB data per hour
|
|
*/
|
|
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
class MLS_Rate_Limiter {
|
|
|
|
/**
|
|
* Rate limit constants
|
|
*/
|
|
const LIMIT_PER_SECOND = 2;
|
|
const LIMIT_PER_HOUR = 7200;
|
|
const LIMIT_PER_DAY = 40000;
|
|
const LIMIT_BYTES_PER_HOUR = 4294967296; // 4GB
|
|
|
|
/**
|
|
* Window types
|
|
*/
|
|
const WINDOW_SECOND = 'second';
|
|
const WINDOW_HOUR = 'hour';
|
|
const WINDOW_DAY = 'day';
|
|
|
|
/**
|
|
* Database instance
|
|
*/
|
|
private $db;
|
|
|
|
/**
|
|
* Last request timestamp for per-second limiting
|
|
*/
|
|
private $last_request_time = 0;
|
|
|
|
/**
|
|
* Constructor
|
|
*
|
|
* @param MLS_DB $db Database instance
|
|
*/
|
|
public function __construct(MLS_DB $db) {
|
|
$this->db = $db;
|
|
}
|
|
|
|
/**
|
|
* Check if we can make a request (and wait if needed)
|
|
*
|
|
* @param bool $wait Whether to wait if rate limited
|
|
* @return bool True if request can proceed
|
|
*/
|
|
public function check_and_wait($wait = true) {
|
|
// Check per-second limit (most restrictive)
|
|
$this->enforce_per_second_limit();
|
|
|
|
// Check hourly limit
|
|
if (!$this->check_limit(self::WINDOW_HOUR, self::LIMIT_PER_HOUR)) {
|
|
if ($wait) {
|
|
$this->wait_for_window(self::WINDOW_HOUR);
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check daily limit
|
|
if (!$this->check_limit(self::WINDOW_DAY, self::LIMIT_PER_DAY)) {
|
|
if ($wait) {
|
|
$this->wait_for_window(self::WINDOW_DAY);
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Enforce per-second rate limit
|
|
*/
|
|
private function enforce_per_second_limit() {
|
|
$now = microtime(true);
|
|
$min_interval = 1.0 / self::LIMIT_PER_SECOND; // 0.5 seconds
|
|
|
|
if ($this->last_request_time > 0) {
|
|
$elapsed = $now - $this->last_request_time;
|
|
if ($elapsed < $min_interval) {
|
|
$sleep_time = ($min_interval - $elapsed) * 1000000; // microseconds
|
|
usleep((int) $sleep_time);
|
|
}
|
|
}
|
|
|
|
$this->last_request_time = microtime(true);
|
|
}
|
|
|
|
/**
|
|
* Check if under the limit for a window type
|
|
*
|
|
* @param string $window_type Window type
|
|
* @param int $limit Limit for this window
|
|
* @return bool True if under limit
|
|
*/
|
|
private function check_limit($window_type, $limit) {
|
|
$count = $this->get_window_count($window_type);
|
|
return $count < $limit;
|
|
}
|
|
|
|
/**
|
|
* Get current count for a window
|
|
*
|
|
* @param string $window_type Window type
|
|
* @return int Current request count
|
|
*/
|
|
private function get_window_count($window_type) {
|
|
global $wpdb;
|
|
|
|
$window_start = $this->get_window_start($window_type);
|
|
|
|
$count = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT request_count FROM {$this->db->rate_limits_table()}
|
|
WHERE window_type = %s AND window_start = %s",
|
|
$window_type,
|
|
$window_start
|
|
));
|
|
|
|
return $count ? (int) $count : 0;
|
|
}
|
|
|
|
/**
|
|
* Get window start time
|
|
*
|
|
* @param string $window_type Window type
|
|
* @return string MySQL datetime
|
|
*/
|
|
private function get_window_start($window_type) {
|
|
$now = current_time('timestamp');
|
|
|
|
switch ($window_type) {
|
|
case self::WINDOW_SECOND:
|
|
return gmdate('Y-m-d H:i:s', $now);
|
|
|
|
case self::WINDOW_HOUR:
|
|
return gmdate('Y-m-d H:00:00', $now);
|
|
|
|
case self::WINDOW_DAY:
|
|
return gmdate('Y-m-d 00:00:00', $now);
|
|
|
|
default:
|
|
return gmdate('Y-m-d H:i:s', $now);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record a request
|
|
*
|
|
* @param int $bytes_transferred Optional bytes transferred
|
|
*/
|
|
public function record_request($bytes_transferred = 0) {
|
|
global $wpdb;
|
|
|
|
// Record for hourly window
|
|
$this->increment_window(self::WINDOW_HOUR, $bytes_transferred);
|
|
|
|
// Record for daily window
|
|
$this->increment_window(self::WINDOW_DAY, $bytes_transferred);
|
|
|
|
// Clean up old records
|
|
$this->cleanup_old_records();
|
|
}
|
|
|
|
/**
|
|
* Increment count for a window
|
|
*
|
|
* @param string $window_type Window type
|
|
* @param int $bytes_transferred Bytes transferred
|
|
*/
|
|
private function increment_window($window_type, $bytes_transferred = 0) {
|
|
global $wpdb;
|
|
|
|
$window_start = $this->get_window_start($window_type);
|
|
|
|
// Try to update existing record
|
|
$updated = $wpdb->query($wpdb->prepare(
|
|
"UPDATE {$this->db->rate_limits_table()}
|
|
SET request_count = request_count + 1,
|
|
bytes_transferred = bytes_transferred + %d
|
|
WHERE window_type = %s AND window_start = %s",
|
|
$bytes_transferred,
|
|
$window_type,
|
|
$window_start
|
|
));
|
|
|
|
// If no record existed, insert new one
|
|
if (0 === $updated) {
|
|
$wpdb->insert(
|
|
$this->db->rate_limits_table(),
|
|
array(
|
|
'window_type' => $window_type,
|
|
'window_start' => $window_start,
|
|
'request_count' => 1,
|
|
'bytes_transferred' => $bytes_transferred,
|
|
),
|
|
array('%s', '%s', '%d', '%d')
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait for a rate limit window to reset
|
|
*
|
|
* @param string $window_type Window type
|
|
*/
|
|
private function wait_for_window($window_type) {
|
|
$now = current_time('timestamp');
|
|
|
|
switch ($window_type) {
|
|
case self::WINDOW_HOUR:
|
|
// Wait until next hour
|
|
$next_hour = strtotime('+1 hour', strtotime(gmdate('Y-m-d H:00:00', $now)));
|
|
$wait_seconds = $next_hour - $now;
|
|
break;
|
|
|
|
case self::WINDOW_DAY:
|
|
// Wait until next day
|
|
$next_day = strtotime('+1 day', strtotime(gmdate('Y-m-d 00:00:00', $now)));
|
|
$wait_seconds = $next_day - $now;
|
|
break;
|
|
|
|
default:
|
|
$wait_seconds = 1;
|
|
}
|
|
|
|
if ($wait_seconds > 0) {
|
|
sleep(min($wait_seconds, 60)); // Max 60 second wait per call
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up old rate limit records
|
|
*/
|
|
private function cleanup_old_records() {
|
|
global $wpdb;
|
|
|
|
// Delete records older than 48 hours
|
|
$cutoff = gmdate('Y-m-d H:i:s', strtotime('-48 hours'));
|
|
|
|
$wpdb->query($wpdb->prepare(
|
|
"DELETE FROM {$this->db->rate_limits_table()} WHERE window_start < %s",
|
|
$cutoff
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Get current rate limit status
|
|
*
|
|
* @return array Rate limit status
|
|
*/
|
|
public function get_status() {
|
|
return array(
|
|
'hourly' => array(
|
|
'used' => $this->get_window_count(self::WINDOW_HOUR),
|
|
'limit' => self::LIMIT_PER_HOUR,
|
|
'remaining' => max(0, self::LIMIT_PER_HOUR - $this->get_window_count(self::WINDOW_HOUR)),
|
|
),
|
|
'daily' => array(
|
|
'used' => $this->get_window_count(self::WINDOW_DAY),
|
|
'limit' => self::LIMIT_PER_DAY,
|
|
'remaining' => max(0, self::LIMIT_PER_DAY - $this->get_window_count(self::WINDOW_DAY)),
|
|
),
|
|
'bytes_this_hour' => $this->get_bytes_this_hour(),
|
|
'bytes_limit' => self::LIMIT_BYTES_PER_HOUR,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get bytes transferred this hour
|
|
*
|
|
* @return int Bytes
|
|
*/
|
|
private function get_bytes_this_hour() {
|
|
global $wpdb;
|
|
|
|
$window_start = $this->get_window_start(self::WINDOW_HOUR);
|
|
|
|
$bytes = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT bytes_transferred FROM {$this->db->rate_limits_table()}
|
|
WHERE window_type = %s AND window_start = %s",
|
|
self::WINDOW_HOUR,
|
|
$window_start
|
|
));
|
|
|
|
return $bytes ? (int) $bytes : 0;
|
|
}
|
|
|
|
/**
|
|
* Check if we're approaching rate limits
|
|
*
|
|
* @param float $threshold Percentage threshold (0.0 - 1.0)
|
|
* @return bool True if approaching limits
|
|
*/
|
|
public function is_approaching_limit($threshold = 0.9) {
|
|
$status = $this->get_status();
|
|
|
|
$hourly_pct = $status['hourly']['used'] / $status['hourly']['limit'];
|
|
$daily_pct = $status['daily']['used'] / $status['daily']['limit'];
|
|
|
|
return $hourly_pct >= $threshold || $daily_pct >= $threshold;
|
|
}
|
|
|
|
/**
|
|
* Reset all rate limit counters (for testing)
|
|
*/
|
|
public function reset() {
|
|
global $wpdb;
|
|
$wpdb->query("TRUNCATE TABLE {$this->db->rate_limits_table()}");
|
|
$this->last_request_time = 0;
|
|
}
|
|
}
|