PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.13
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.13
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 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / includes / class-metasync-api-backoff-manager.php

class-metasync-api-backoff-manager.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.13, at includes/class-metasync-api-backoff-manager.php

542 lines 15.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * API Backoff Manager
4 *
5 * Handles exponential backoff for HTTP 429/503 responses from SearchAtlas APIs.
6 * Implements adaptive backoff strategy with persistent state management.
7 *
8 * @package Metasync
9 * @subpackage Metasync/includes
10 * @since 2.5.15
11 */
12
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 /**
18 * Class Metasync_API_Backoff_Manager
19 *
20 * Manages exponential backoff for API rate limiting and service unavailability.
21 * Features:
22 * - Per-endpoint backoff tracking
23 * - Exponential backoff strategy (5, 10, 15 minutes)
24 * - Automatic counter reset after 1 hour of successful requests
25 * - Persistent state using WordPress transients
26 * - Multi-site support
27 */
28 class Metasync_API_Backoff_Manager {
29
30 /**
31 * Singleton instance
32 *
33 * @var Metasync_API_Backoff_Manager|null
34 */
35 private static $instance = null;
36
37 /**
38 * Backoff transient prefix
39 */
40 private const BACKOFF_PREFIX = 'metasync_api_backoff_';
41
42 /**
43 * Counter transient prefix
44 */
45 private const COUNTER_PREFIX = 'metasync_api_counter_';
46
47 /**
48 * Last success transient prefix
49 */
50 private const LAST_SUCCESS_PREFIX = 'metasync_api_last_success_';
51
52 /**
53 * Backoff durations in seconds
54 */
55 private const BACKOFF_DURATIONS = [
56 1 => 300, // 5 minutes
57 2 => 600, // 10 minutes
58 3 => 900, // 15 minutes
59 ];
60
61 /**
62 * Counter reset window (1 hour in seconds)
63 */
64 private const RESET_WINDOW = 3600;
65
66 /**
67 * HTTP codes that trigger backoff
68 */
69 private const TRIGGER_CODES = [429, 503];
70
71 /**
72 * Monitored endpoints (domain patterns)
73 */
74 private const MONITORED_ENDPOINTS = [
75 'sa.searchatlas.com',
76 'api.searchatlas.com',
77 'ca.searchatlas.com',
78 'sa.staging.searchatlas.com',
79 'api.staging.searchatlas.com',
80 'ca.staging.searchatlas.com',
81 ];
82
83 /**
84 * Private constructor for singleton pattern
85 */
86 private function __construct() {
87 // Initialize hooks
88 $this->init_hooks();
89 }
90
91 /**
92 * Get singleton instance
93 *
94 * @return Metasync_API_Backoff_Manager
95 */
96 public static function get_instance() {
97 if (self::$instance === null) {
98 self::$instance = new self();
99 }
100 return self::$instance;
101 }
102
103 /**
104 * Initialize WordPress hooks
105 */
106 private function init_hooks() {
107 // Hook into HTTP API responses
108 add_filter('http_response', [$this, 'intercept_http_response'], 10, 3);
109
110 // Hook to check backoff before making requests
111 add_filter('pre_http_request', [$this, 'check_backoff_before_request'], 10, 3);
112 }
113
114 /**
115 * Intercept HTTP responses to detect 429/503 errors
116 *
117 * @param array|WP_Error $response HTTP response or WP_Error.
118 * @param array $args HTTP request arguments.
119 * @param string $url The request URL.
120 * @return array|WP_Error
121 */
122 public function intercept_http_response($response, $args, $url) {
123 // Skip if response is WP_Error
124 if (is_wp_error($response)) {
125 return $response;
126 }
127
128 // Check if URL is from monitored endpoints
129 if (!$this->is_monitored_endpoint($url)) {
130 return $response;
131 }
132
133 // Get response code
134 $response_code = wp_remote_retrieve_response_code($response);
135
136 // Check if response code triggers backoff
137 if (in_array($response_code, self::TRIGGER_CODES, true)) {
138 $this->handle_rate_limit_response($url, $response_code);
139 } else if ($response_code >= 200 && $response_code < 300) {
140 // Successful response - update last success timestamp
141 $this->record_successful_request($url);
142 }
143
144 return $response;
145 }
146
147 /**
148 * Check if endpoint is in backoff before making request
149 *
150 * @param false|array|WP_Error $preempt Whether to preempt an HTTP request's return value.
151 * @param array $args HTTP request arguments.
152 * @param string $url The request URL.
153 * @return false|array|WP_Error
154 */
155 public function check_backoff_before_request($preempt, $args, $url) {
156 // Skip if not a monitored endpoint
157 if (!$this->is_monitored_endpoint($url)) {
158 return $preempt;
159 }
160
161 // Check if endpoint is in backoff
162 if ($this->is_endpoint_in_backoff($url)) {
163 $endpoint_hash = $this->get_endpoint_hash($url);
164 $backoff_data = $this->get_backoff_state($endpoint_hash);
165
166 // Log the blocked request
167 $this->log_backoff_event(
168 'API_BACKOFF_BLOCKED',
169 sprintf(
170 'Request blocked due to active backoff. Endpoint: %s, Time remaining: %d seconds',
171 $this->extract_endpoint($url),
172 $backoff_data['time_remaining']
173 )
174 );
175
176 // Return WP_Error to prevent the request
177 return new WP_Error(
178 'api_backoff_active',
179 sprintf(
180 'API endpoint is in backoff mode. Please wait %d seconds before retrying.',
181 $backoff_data['time_remaining']
182 ),
183 [
184 'endpoint' => $this->extract_endpoint($url),
185 'time_remaining' => $backoff_data['time_remaining'],
186 'occurrence_count' => $backoff_data['occurrence_count'],
187 ]
188 );
189 }
190
191 return $preempt;
192 }
193
194 /**
195 * Handle rate limit response (429/503)
196 *
197 * @param string $url The request URL.
198 * @param int $response_code HTTP response code.
199 */
200 private function handle_rate_limit_response($url, $response_code) {
201 $endpoint_hash = $this->get_endpoint_hash($url);
202 $endpoint = $this->extract_endpoint($url);
203
204 // Check if we should reset counter based on last success
205 $this->maybe_reset_counter($endpoint_hash);
206
207 // Increment occurrence counter
208 $occurrence_count = $this->increment_occurrence_counter($endpoint_hash);
209
210 // Cap at 3 occurrences
211 $occurrence_count = min($occurrence_count, 3);
212
213 // Get backoff duration
214 $backoff_duration = self::BACKOFF_DURATIONS[$occurrence_count];
215
216 // Store backoff state
217 $this->set_backoff_state($endpoint_hash, [
218 'endpoint' => $endpoint,
219 'occurrence_count' => $occurrence_count,
220 'backoff_duration' => $backoff_duration,
221 'response_code' => $response_code,
222 'triggered_at' => current_time('timestamp'),
223 'expires_at' => current_time('timestamp') + $backoff_duration,
224 ]);
225
226 // Log the backoff event
227 $this->log_backoff_event(
228 'API_BACKOFF_TRIGGERED',
229 sprintf(
230 'Backoff triggered for endpoint: %s | Response Code: %d | Occurrence: %d/3 | Duration: %d seconds',
231 $endpoint,
232 $response_code,
233 $occurrence_count,
234 $backoff_duration
235 )
236 );
237
238 // Trigger action for other components (e.g., admin notices)
239 do_action('metasync_api_backoff_triggered', [
240 'endpoint' => $endpoint,
241 'endpoint_hash' => $endpoint_hash,
242 'occurrence_count' => $occurrence_count,
243 'backoff_duration' => $backoff_duration,
244 'response_code' => $response_code,
245 ]);
246 }
247
248 /**
249 * Record successful request timestamp
250 *
251 * @param string $url The request URL.
252 */
253 private function record_successful_request($url) {
254 $endpoint_hash = $this->get_endpoint_hash($url);
255 $timestamp = current_time('timestamp');
256
257 set_transient(
258 self::LAST_SUCCESS_PREFIX . $endpoint_hash,
259 $timestamp,
260 self::RESET_WINDOW * 2 // Keep for 2 hours
261 );
262
263 // Check if we should reset counter
264 $this->maybe_reset_counter($endpoint_hash);
265 }
266
267 /**
268 * Maybe reset occurrence counter based on last success
269 *
270 * @param string $endpoint_hash The endpoint hash.
271 */
272 private function maybe_reset_counter($endpoint_hash) {
273 $last_success = get_transient(self::LAST_SUCCESS_PREFIX . $endpoint_hash);
274 $current_time = current_time('timestamp');
275
276 // Reset counter if last success was more than 1 hour ago
277 if ($last_success !== false && ($current_time - $last_success) >= self::RESET_WINDOW) {
278 $this->reset_occurrence_counter($endpoint_hash);
279
280 $this->log_backoff_event(
281 'API_BACKOFF_COUNTER_RESET',
282 sprintf(
283 'Counter reset for endpoint hash: %s (1 hour of successful requests)',
284 $endpoint_hash
285 )
286 );
287 }
288 }
289
290 /**
291 * Increment occurrence counter
292 *
293 * @param string $endpoint_hash The endpoint hash.
294 * @return int New counter value.
295 */
296 private function increment_occurrence_counter($endpoint_hash) {
297 $counter_key = self::COUNTER_PREFIX . $endpoint_hash;
298 $count = (int) get_transient($counter_key);
299 $count++;
300
301 // Store with 2-hour expiry (longer than reset window)
302 set_transient($counter_key, $count, self::RESET_WINDOW * 2);
303
304 return $count;
305 }
306
307 /**
308 * Reset occurrence counter
309 *
310 * @param string $endpoint_hash The endpoint hash.
311 */
312 private function reset_occurrence_counter($endpoint_hash) {
313 $counter_key = self::COUNTER_PREFIX . $endpoint_hash;
314 delete_transient($counter_key);
315 }
316
317 /**
318 * Set backoff state
319 *
320 * @param string $endpoint_hash The endpoint hash.
321 * @param array $state Backoff state data.
322 */
323 private function set_backoff_state($endpoint_hash, array $state) {
324 $backoff_key = self::BACKOFF_PREFIX . $endpoint_hash;
325 set_transient($backoff_key, $state, $state['backoff_duration']);
326 }
327
328 /**
329 * Get backoff state
330 *
331 * @param string $endpoint_hash The endpoint hash.
332 * @return array|false Backoff state or false if not in backoff.
333 */
334 public function get_backoff_state($endpoint_hash) {
335 $backoff_key = self::BACKOFF_PREFIX . $endpoint_hash;
336 $state = get_transient($backoff_key);
337
338 if ($state === false) {
339 return false;
340 }
341
342 // Calculate time remaining
343 $state['time_remaining'] = max(0, $state['expires_at'] - current_time('timestamp'));
344
345 return $state;
346 }
347
348 /**
349 * Check if endpoint is currently in backoff
350 *
351 * @param string $url The request URL.
352 * @return bool True if in backoff, false otherwise.
353 */
354 public function is_endpoint_in_backoff($url) {
355 $endpoint_hash = $this->get_endpoint_hash($url);
356 $state = $this->get_backoff_state($endpoint_hash);
357
358 return $state !== false && $state['time_remaining'] > 0;
359 }
360
361 /**
362 * Get all active backoffs
363 *
364 * @return array Array of active backoff states.
365 */
366 public function get_all_active_backoffs() {
367 global $wpdb;
368
369 $backoffs = [];
370
371 // Query all backoff transients
372 $transient_keys = $wpdb->get_col(
373 $wpdb->prepare(
374 "SELECT option_name FROM {$wpdb->options}
375 WHERE option_name LIKE %s",
376 '_transient_' . self::BACKOFF_PREFIX . '%'
377 )
378 );
379
380 foreach ($transient_keys as $key) {
381 $endpoint_hash = str_replace('_transient_' . self::BACKOFF_PREFIX, '', $key);
382 $state = $this->get_backoff_state($endpoint_hash);
383
384 if ($state !== false && $state['time_remaining'] > 0) {
385 $state['endpoint_hash'] = $endpoint_hash;
386 $backoffs[] = $state;
387 }
388 }
389
390 return $backoffs;
391 }
392
393 /**
394 * Clear backoff for specific endpoint
395 *
396 * @param string $endpoint_hash The endpoint hash.
397 * @return bool Success status.
398 */
399 public function clear_backoff($endpoint_hash) {
400 $backoff_key = self::BACKOFF_PREFIX . $endpoint_hash;
401 $deleted = delete_transient($backoff_key);
402
403 if ($deleted) {
404 $this->log_backoff_event(
405 'API_BACKOFF_CLEARED',
406 sprintf('Backoff manually cleared for endpoint hash: %s', $endpoint_hash)
407 );
408 }
409
410 return $deleted;
411 }
412
413 /**
414 * Clear all backoffs
415 *
416 * @return int Number of backoffs cleared.
417 */
418 public function clear_all_backoffs() {
419 global $wpdb;
420
421 $cleared_count = 0;
422 $prefixes = [
423 self::BACKOFF_PREFIX,
424 self::COUNTER_PREFIX,
425 self::LAST_SUCCESS_PREFIX,
426 ];
427
428 foreach ($prefixes as $prefix) {
429 $transient_keys = $wpdb->get_col(
430 $wpdb->prepare(
431 "SELECT option_name FROM {$wpdb->options}
432 WHERE option_name LIKE %s
433 OR option_name LIKE %s",
434 '_transient_' . $prefix . '%',
435 '_transient_timeout_' . $prefix . '%'
436 )
437 );
438
439 foreach ($transient_keys as $key) {
440 $transient_name = str_replace(['_transient_', '_transient_timeout_'], '', $key);
441 delete_transient($transient_name);
442 $cleared_count++;
443 }
444 }
445
446 $this->log_backoff_event(
447 'API_BACKOFF_ALL_CLEARED',
448 sprintf('All backoffs cleared. Count: %d', $cleared_count)
449 );
450
451 return $cleared_count;
452 }
453
454 /**
455 * Get endpoint hash for URL
456 *
457 * @param string $url The request URL.
458 * @return string Endpoint hash.
459 */
460 private function get_endpoint_hash($url) {
461 $endpoint = $this->extract_endpoint($url);
462 return md5($endpoint);
463 }
464
465 /**
466 * Extract endpoint domain from URL
467 *
468 * @param string $url The request URL.
469 * @return string Endpoint domain.
470 */
471 private function extract_endpoint($url) {
472 $parsed = wp_parse_url($url);
473 return $parsed['host'] ?? '';
474 }
475
476 /**
477 * Check if URL is from monitored endpoint
478 *
479 * @param string $url The request URL.
480 * @return bool True if monitored, false otherwise.
481 */
482 private function is_monitored_endpoint($url) {
483 $endpoint = $this->extract_endpoint($url);
484
485 foreach (self::MONITORED_ENDPOINTS as $monitored) {
486 if (strpos($endpoint, $monitored) !== false) {
487 return true;
488 }
489 }
490
491 return false;
492 }
493
494 /**
495 * Log backoff event
496 *
497 * @param string $event_type Event type identifier.
498 * @param string $message Log message.
499 */
500 private function log_backoff_event($event_type, $message) {
501 // Backoff events are operational noise; suppress from error log.
502 }
503
504 /**
505 * Get formatted time remaining
506 *
507 * @param int $seconds Seconds remaining.
508 * @return string Formatted time string.
509 */
510 public static function format_time_remaining($seconds) {
511 if ($seconds < 60) {
512 return sprintf('%d seconds', $seconds);
513 }
514
515 $minutes = floor($seconds / 60);
516 $remaining_seconds = $seconds % 60;
517
518 if ($remaining_seconds > 0) {
519 return sprintf('%d minutes %d seconds', $minutes, $remaining_seconds);
520 }
521
522 return sprintf('%d minutes', $minutes);
523 }
524
525 /**
526 * Get statistics
527 *
528 * @return array Statistics data.
529 */
530 public function get_statistics() {
531 $active_backoffs = $this->get_all_active_backoffs();
532
533 return [
534 'active_backoffs_count' => count($active_backoffs),
535 'active_backoffs' => $active_backoffs,
536 'monitored_endpoints' => self::MONITORED_ENDPOINTS,
537 'backoff_durations' => self::BACKOFF_DURATIONS,
538 'reset_window_seconds' => self::RESET_WINDOW,
539 ];
540 }
541 }
542