PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
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 trunk, at includes/class-metasync-api-backoff-manager.php

559 lines 16.8 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 // Deliberately NOT logged. This runs once per blocked request, so on a
167 // busy site in backoff it would write a log line and an error-summary
168 // row per request — write amplification at precisely the moment the
169 // site is already struggling. The state transitions below
170 // (triggered / cleared / counter reset) are what carry the diagnostic
171 // value, and they fire once each.
172
173 // Return WP_Error to prevent the request
174 return new WP_Error(
175 'api_backoff_active',
176 sprintf(
177 'API endpoint is in backoff mode. Please wait %d seconds before retrying.',
178 $backoff_data['time_remaining']
179 ),
180 [
181 'endpoint' => $this->extract_endpoint($url),
182 'time_remaining' => $backoff_data['time_remaining'],
183 'occurrence_count' => $backoff_data['occurrence_count'],
184 ]
185 );
186 }
187
188 return $preempt;
189 }
190
191 /**
192 * Handle rate limit response (429/503)
193 *
194 * @param string $url The request URL.
195 * @param int $response_code HTTP response code.
196 */
197 private function handle_rate_limit_response($url, $response_code) {
198 $endpoint_hash = $this->get_endpoint_hash($url);
199 $endpoint = $this->extract_endpoint($url);
200
201 // Check if we should reset counter based on last success
202 $this->maybe_reset_counter($endpoint_hash);
203
204 // Increment occurrence counter
205 $occurrence_count = $this->increment_occurrence_counter($endpoint_hash);
206
207 // Cap at 3 occurrences
208 $occurrence_count = min($occurrence_count, 3);
209
210 // Get backoff duration
211 $backoff_duration = self::BACKOFF_DURATIONS[$occurrence_count];
212
213 // Store backoff state
214 $this->set_backoff_state($endpoint_hash, [
215 'endpoint' => $endpoint,
216 'occurrence_count' => $occurrence_count,
217 'backoff_duration' => $backoff_duration,
218 'response_code' => $response_code,
219 'triggered_at' => current_time('timestamp'),
220 'expires_at' => current_time('timestamp') + $backoff_duration,
221 ]);
222
223 // Log the backoff event
224 $this->log_backoff_event(
225 'API_BACKOFF_TRIGGERED',
226 sprintf(
227 'Backoff triggered for endpoint: %s | Response Code: %d | Occurrence: %d/3 | Duration: %d seconds',
228 $endpoint,
229 $response_code,
230 $occurrence_count,
231 $backoff_duration
232 )
233 );
234
235 // Trigger action for other components (e.g., admin notices)
236 do_action('metasync_api_backoff_triggered', [
237 'endpoint' => $endpoint,
238 'endpoint_hash' => $endpoint_hash,
239 'occurrence_count' => $occurrence_count,
240 'backoff_duration' => $backoff_duration,
241 'response_code' => $response_code,
242 ]);
243 }
244
245 /**
246 * Record successful request timestamp
247 *
248 * @param string $url The request URL.
249 */
250 private function record_successful_request($url) {
251 $endpoint_hash = $this->get_endpoint_hash($url);
252 $timestamp = current_time('timestamp');
253
254 set_transient(
255 self::LAST_SUCCESS_PREFIX . $endpoint_hash,
256 $timestamp,
257 self::RESET_WINDOW * 2 // Keep for 2 hours
258 );
259
260 // Check if we should reset counter
261 $this->maybe_reset_counter($endpoint_hash);
262 }
263
264 /**
265 * Maybe reset occurrence counter based on last success
266 *
267 * @param string $endpoint_hash The endpoint hash.
268 */
269 private function maybe_reset_counter($endpoint_hash) {
270 $last_success = get_transient(self::LAST_SUCCESS_PREFIX . $endpoint_hash);
271 $current_time = current_time('timestamp');
272
273 // Reset counter if last success was more than 1 hour ago
274 if ($last_success !== false && ($current_time - $last_success) >= self::RESET_WINDOW) {
275 $this->reset_occurrence_counter($endpoint_hash);
276
277 $this->log_backoff_event(
278 'API_BACKOFF_COUNTER_RESET',
279 sprintf(
280 'Counter reset for endpoint hash: %s (1 hour of successful requests)',
281 $endpoint_hash
282 )
283 );
284 }
285 }
286
287 /**
288 * Increment occurrence counter
289 *
290 * @param string $endpoint_hash The endpoint hash.
291 * @return int New counter value.
292 */
293 private function increment_occurrence_counter($endpoint_hash) {
294 $counter_key = self::COUNTER_PREFIX . $endpoint_hash;
295 $count = (int) get_transient($counter_key);
296 $count++;
297
298 // Store with 2-hour expiry (longer than reset window)
299 set_transient($counter_key, $count, self::RESET_WINDOW * 2);
300
301 return $count;
302 }
303
304 /**
305 * Reset occurrence counter
306 *
307 * @param string $endpoint_hash The endpoint hash.
308 */
309 private function reset_occurrence_counter($endpoint_hash) {
310 $counter_key = self::COUNTER_PREFIX . $endpoint_hash;
311 delete_transient($counter_key);
312 }
313
314 /**
315 * Set backoff state
316 *
317 * @param string $endpoint_hash The endpoint hash.
318 * @param array $state Backoff state data.
319 */
320 private function set_backoff_state($endpoint_hash, array $state) {
321 $backoff_key = self::BACKOFF_PREFIX . $endpoint_hash;
322 set_transient($backoff_key, $state, $state['backoff_duration']);
323 }
324
325 /**
326 * Get backoff state
327 *
328 * @param string $endpoint_hash The endpoint hash.
329 * @return array|false Backoff state or false if not in backoff.
330 */
331 public function get_backoff_state($endpoint_hash) {
332 $backoff_key = self::BACKOFF_PREFIX . $endpoint_hash;
333 $state = get_transient($backoff_key);
334
335 if ($state === false) {
336 return false;
337 }
338
339 // Calculate time remaining
340 $state['time_remaining'] = max(0, $state['expires_at'] - current_time('timestamp'));
341
342 return $state;
343 }
344
345 /**
346 * Check if endpoint is currently in backoff
347 *
348 * @param string $url The request URL.
349 * @return bool True if in backoff, false otherwise.
350 */
351 public function is_endpoint_in_backoff($url) {
352 $endpoint_hash = $this->get_endpoint_hash($url);
353 $state = $this->get_backoff_state($endpoint_hash);
354
355 return $state !== false && $state['time_remaining'] > 0;
356 }
357
358 /**
359 * Get all active backoffs
360 *
361 * @return array Array of active backoff states.
362 */
363 public function get_all_active_backoffs() {
364 global $wpdb;
365
366 $backoffs = [];
367
368 // Query all backoff transients
369 $transient_keys = $wpdb->get_col(
370 $wpdb->prepare(
371 "SELECT option_name FROM {$wpdb->options}
372 WHERE option_name LIKE %s",
373 '_transient_' . self::BACKOFF_PREFIX . '%'
374 )
375 );
376
377 foreach ($transient_keys as $key) {
378 $endpoint_hash = str_replace('_transient_' . self::BACKOFF_PREFIX, '', $key);
379 $state = $this->get_backoff_state($endpoint_hash);
380
381 if ($state !== false && $state['time_remaining'] > 0) {
382 $state['endpoint_hash'] = $endpoint_hash;
383 $backoffs[] = $state;
384 }
385 }
386
387 return $backoffs;
388 }
389
390 /**
391 * Clear backoff for specific endpoint
392 *
393 * @param string $endpoint_hash The endpoint hash.
394 * @return bool Success status.
395 */
396 public function clear_backoff($endpoint_hash) {
397 $backoff_key = self::BACKOFF_PREFIX . $endpoint_hash;
398 $deleted = delete_transient($backoff_key);
399
400 if ($deleted) {
401 $this->log_backoff_event(
402 'API_BACKOFF_CLEARED',
403 sprintf('Backoff manually cleared for endpoint hash: %s', $endpoint_hash)
404 );
405 }
406
407 return $deleted;
408 }
409
410 /**
411 * Clear all backoffs
412 *
413 * @return int Number of backoffs cleared.
414 */
415 public function clear_all_backoffs() {
416 global $wpdb;
417
418 $cleared_count = 0;
419 $prefixes = [
420 self::BACKOFF_PREFIX,
421 self::COUNTER_PREFIX,
422 self::LAST_SUCCESS_PREFIX,
423 ];
424
425 foreach ($prefixes as $prefix) {
426 $transient_keys = $wpdb->get_col(
427 $wpdb->prepare(
428 "SELECT option_name FROM {$wpdb->options}
429 WHERE option_name LIKE %s
430 OR option_name LIKE %s",
431 '_transient_' . $prefix . '%',
432 '_transient_timeout_' . $prefix . '%'
433 )
434 );
435
436 foreach ($transient_keys as $key) {
437 $transient_name = str_replace(['_transient_', '_transient_timeout_'], '', $key);
438 delete_transient($transient_name);
439 $cleared_count++;
440 }
441 }
442
443 $this->log_backoff_event(
444 'API_BACKOFF_ALL_CLEARED',
445 sprintf('All backoffs cleared. Count: %d', $cleared_count)
446 );
447
448 return $cleared_count;
449 }
450
451 /**
452 * Get endpoint hash for URL
453 *
454 * @param string $url The request URL.
455 * @return string Endpoint hash.
456 */
457 private function get_endpoint_hash($url) {
458 $endpoint = $this->extract_endpoint($url);
459 return md5($endpoint);
460 }
461
462 /**
463 * Extract endpoint domain from URL
464 *
465 * @param string $url The request URL.
466 * @return string Endpoint domain.
467 */
468 private function extract_endpoint($url) {
469 $parsed = wp_parse_url($url);
470 return $parsed['host'] ?? '';
471 }
472
473 /**
474 * Check if URL is from monitored endpoint
475 *
476 * @param string $url The request URL.
477 * @return bool True if monitored, false otherwise.
478 */
479 private function is_monitored_endpoint($url) {
480 $endpoint = $this->extract_endpoint($url);
481
482 foreach (self::MONITORED_ENDPOINTS as $monitored) {
483 if (strpos($endpoint, $monitored) !== false) {
484 return true;
485 }
486 }
487
488 return false;
489 }
490
491 /**
492 * Log backoff event
493 *
494 * @param string $event_type Event type identifier.
495 * @param string $message Log message.
496 */
497 private function log_backoff_event($event_type, $message) {
498 // This was previously an empty stub, which made fleet-wide backoff behaviour
499 // completely invisible — we could not tell from a customer's logs whether
500 // backoff had triggered, blocked, or cleared.
501 //
502 // Routed through Metasync_Error_Logger rather than error_log() so it stays
503 // suppressed by default: CATEGORY_API_BACKOFF is in DEBUG_ONLY_CATEGORIES,
504 // so nothing is written unless Debug Mode is on. That keeps the original
505 // "operational noise" concern satisfied while making diagnosis possible.
506 if (!class_exists('Metasync_Error_Logger')) {
507 return;
508 }
509
510 Metasync_Error_Logger::log(
511 Metasync_Error_Logger::CATEGORY_API_BACKOFF,
512 Metasync_Error_Logger::SEVERITY_INFO,
513 $message,
514 [
515 'event_type' => $event_type,
516 'operation' => 'api_backoff',
517 ]
518 );
519 }
520
521 /**
522 * Get formatted time remaining
523 *
524 * @param int $seconds Seconds remaining.
525 * @return string Formatted time string.
526 */
527 public static function format_time_remaining($seconds) {
528 if ($seconds < 60) {
529 return sprintf('%d seconds', $seconds);
530 }
531
532 $minutes = floor($seconds / 60);
533 $remaining_seconds = $seconds % 60;
534
535 if ($remaining_seconds > 0) {
536 return sprintf('%d minutes %d seconds', $minutes, $remaining_seconds);
537 }
538
539 return sprintf('%d minutes', $minutes);
540 }
541
542 /**
543 * Get statistics
544 *
545 * @return array Statistics data.
546 */
547 public function get_statistics() {
548 $active_backoffs = $this->get_all_active_backoffs();
549
550 return [
551 'active_backoffs_count' => count($active_backoffs),
552 'active_backoffs' => $active_backoffs,
553 'monitored_endpoints' => self::MONITORED_ENDPOINTS,
554 'backoff_durations' => self::BACKOFF_DURATIONS,
555 'reset_window_seconds' => self::RESET_WINDOW,
556 ];
557 }
558 }
559