Files
homeproz/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-rate-limiter.php
T
root 57b752f54e Manual property enhancements: MLS status sync, agent clone, description formatting
- Manual properties linked to MLS now inherit status (Active/Pending/Closed) and
  days_on_market from the MLS listing dynamically
- Properties not in MLS default to Closed status
- Clone feature now auto-populates listing agent by matching MLS ID to Agent CPT
- Description formatter detects embedded headers (unpunctuated text after sentences)
  and splits them into separate paragraphs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:28:44 +00:00

480 lines
15 KiB
PHP
Executable File

<?php
/**
* Rate limiter class for MLS Grid API compliance
*
* MLS Grid Rate Limits (warning thresholds):
* - 4 requests per second (suspension at 6)
* - 7,200 requests per hour (suspension at 18,000)
* - 40,000 requests per 24 hours (suspension at 60,000)
* - 3GB data per hour / 40GB per 24 hours (suspension at 4GB/60GB)
*
* Our strategy: Throttle sync operations to use max 50% of daily quota
* even if running continuously for 24 hours. This leaves 50% budget
* for on-demand image fetches and other operations.
*/
if (!defined('ABSPATH')) {
exit;
}
class MLS_Rate_Limiter {
/**
* MLS Grid absolute limits (for reference)
*/
const MLSGRID_LIMIT_PER_SECOND = 4;
const MLSGRID_LIMIT_PER_HOUR = 7200;
const MLSGRID_LIMIT_PER_DAY = 40000;
const MLSGRID_BYTES_PER_HOUR = 3221225472; // 3GB
const MLSGRID_BYTES_PER_DAY = 42949672960; // 40GB
/**
* Sync operation limits (50% of daily quota paced over 24 hours)
*
* Goal: If sync ran continuously for 24h, use max 50% of daily quota
* - 20,000 requests / 86,400 seconds = 0.23 RPS (~4.3s between requests)
* - 20GB data / 86,400 seconds = ~243KB/s average
*/
const SYNC_REQUESTS_PER_DAY = 20000; // 50% of 40,000
const SYNC_BYTES_PER_DAY = 21474836480; // 20GB (50% of 40GB)
const SYNC_MIN_INTERVAL_MS = 4320; // 86400000ms / 20000 = 4.32s between requests
/**
* Rate limit constants (used for tracking against MLS Grid limits)
*/
const LIMIT_PER_HOUR = 7200;
const LIMIT_PER_DAY = 40000;
const LIMIT_BYTES_PER_HOUR = 3221225472; // 3GB
const LIMIT_BYTES_PER_DAY = 42949672960; // 40GB
/**
* Window types
*/
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)
*
* For sync operations, this enforces the 50% daily quota pacing.
* The minimum interval between requests ensures that even continuous
* syncing won't exceed 50% of the daily quota.
*
* @param bool $wait Whether to wait if rate limited
* @return bool True if request can proceed
*/
public function check_and_wait($wait = true) {
// Enforce sync pacing (4.32s between requests for 50% daily quota)
$this->enforce_sync_pacing();
// Check hourly limit (hard stop if approaching MLS Grid limits)
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 (hard stop if approaching MLS Grid limits)
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 sync operation pacing
*
* Ensures minimum interval between sync requests so that
* 24 hours of continuous syncing uses max 50% of daily quota.
*/
private function enforce_sync_pacing() {
$now = microtime(true);
$min_interval = self::SYNC_MIN_INTERVAL_MS / 1000.0; // Convert ms to seconds (4.32s)
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_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() {
$bytes_hour = $this->get_bytes_this_hour();
$bytes_day = $this->get_bytes_today();
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)),
),
'data_hourly' => array(
'used' => $bytes_hour,
'limit' => self::LIMIT_BYTES_PER_HOUR,
'remaining' => max(0, self::LIMIT_BYTES_PER_HOUR - $bytes_hour),
),
'data_daily' => array(
'used' => $bytes_day,
'limit' => self::LIMIT_BYTES_PER_DAY,
'remaining' => max(0, self::LIMIT_BYTES_PER_DAY - $bytes_day),
),
// Legacy fields for backward compatibility
'bytes_this_hour' => $bytes_hour,
'bytes_limit' => self::LIMIT_BYTES_PER_HOUR,
);
}
/**
* Get bytes transferred this hour
*
* @return int Bytes
*/
public 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;
}
/**
* Get bytes transferred today
*
* @return int Bytes
*/
public function get_bytes_today() {
global $wpdb;
$window_start = $this->get_window_start(self::WINDOW_DAY);
$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_DAY,
$window_start
));
return $bytes ? (int) $bytes : 0;
}
/**
* Get remaining daily data budget
*
* @return int Remaining bytes
*/
public function get_daily_data_remaining() {
return max(0, self::LIMIT_BYTES_PER_DAY - $this->get_bytes_today());
}
/**
* Check if we can fetch an image based on remaining daily data budget
*
* @param int $estimated_bytes Estimated size of image (default 400KB)
* @return bool True if we have budget for this image
*/
public function can_fetch_image($estimated_bytes = 409600) {
return $this->get_daily_data_remaining() > $estimated_bytes;
}
/**
* Record data transfer (for image downloads, separate from API requests)
*
* This tracks bytes against the daily data cap without incrementing
* the request count (since image fetches aren't API requests).
*
* @param int $bytes Bytes transferred
*/
public function record_data_transfer($bytes) {
global $wpdb;
if ($bytes <= 0) {
return;
}
// Record for hourly window (data only, no request count)
$this->increment_data_only(self::WINDOW_HOUR, $bytes);
// Record for daily window (data only, no request count)
$this->increment_data_only(self::WINDOW_DAY, $bytes);
}
/**
* Increment data transfer for a window without incrementing request count
*
* @param string $window_type Window type
* @param int $bytes Bytes transferred
*/
private function increment_data_only($window_type, $bytes) {
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 bytes_transferred = bytes_transferred + %d
WHERE window_type = %s AND window_start = %s",
$bytes,
$window_type,
$window_start
));
// If no record existed, insert new one (request_count = 0 since this is data-only)
if (0 === $updated) {
$wpdb->insert(
$this->db->rate_limits_table(),
array(
'window_type' => $window_type,
'window_start' => $window_start,
'request_count' => 0,
'bytes_transferred' => $bytes,
),
array('%s', '%s', '%d', '%d')
);
}
}
/**
* 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'];
$data_daily_pct = $status['data_daily']['used'] / $status['data_daily']['limit'];
return $hourly_pct >= $threshold || $daily_pct >= $threshold || $data_daily_pct >= $threshold;
}
/**
* Get a summary of current usage for logging/display
*
* @return array Summary with percentages
*/
public function get_usage_summary() {
$status = $this->get_status();
return array(
'requests_hourly_pct' => round(($status['hourly']['used'] / $status['hourly']['limit']) * 100, 1),
'requests_daily_pct' => round(($status['daily']['used'] / $status['daily']['limit']) * 100, 1),
'data_hourly_pct' => round(($status['data_hourly']['used'] / $status['data_hourly']['limit']) * 100, 1),
'data_daily_pct' => round(($status['data_daily']['used'] / $status['data_daily']['limit']) * 100, 1),
'data_daily_remaining_gb' => round($status['data_daily']['remaining'] / 1073741824, 2),
);
}
/**
* 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;
}
}