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; } }