| @@ -142,8 +142,37 @@ | ||
| 142 | 142 | |
| 143 | 143 | $api_url = $this->build_otto_api_url($otto_pixel_uuid); |
| 144 | 144 | $headers = $this->prepare_api_headers($jwt_token); |
| 145 | 145 | |
| 146 | + # Bail before the retry loop if the host is already known to be bad. | |
| 147 | + # | |
| 148 | + # Backoff case: without this, all three attempts are rejected instantly by the | |
| 149 | + # pre_http_request filter, but the loop still sleeps 1s + 2s between them, | |
| 150 | + # holding a PHP worker for 3s to accomplish nothing. Measured at 3.01s. | |
| 151 | + # | |
| 152 | + # Breaker case: the retry loop's worst case against a hanging endpoint is | |
| 153 | + # 3 x 15s timeout + 1s + 2s of sleep = 48s of one worker. The suggestions | |
| 154 | + # path runs on the same host, so if its breaker has tripped we already know | |
| 155 | + # this call cannot succeed. This only helps when page traffic has tripped the | |
| 156 | + # breaker first — an admin loading this page on an idle site still pays the | |
| 157 | + # full 48s, which is why the retry budget itself is flagged for follow-up. | |
| 158 | + # | |
| 159 | + # Both helpers are checked with class_provides() rather than class_exists(): | |
| 160 | + # they live in files separate from this one, so a partially updated install | |
| 161 | + # can leave a previous copy of either class loaded that predates the method | |
| 162 | + # being called. See class_provides() for why that matters here. | |
| 163 | + $endpoint_unhealthy = (self::class_provides('Metasync_API_Backoff_Manager', 'is_endpoint_in_backoff') | |
| 164 | + && Metasync_API_Backoff_Manager::get_instance()->is_endpoint_in_backoff($api_url)) | |
| 165 | + || (self::class_provides('Metasync_Otto_Transient_Cache', 'is_host_breaker_open') | |
| 166 | + && Metasync_Otto_Transient_Cache::is_host_breaker_open($api_url)); | |
| 167 | + | |
| 168 | + if ($endpoint_unhealthy) { | |
| 169 | + $this->log_fetch_hash_error('error', 'Public hash fetch skipped - endpoint unhealthy', [ | |
| 170 | + 'uuid' => substr($otto_pixel_uuid, 0, 8) . '...' | |
| 171 | + ]); | |
| 172 | + return false; | |
| 173 | + } | |
| 174 | + | |
| 146 | 175 | for ($attempt = 1; $attempt <= $max_retries; $attempt++) { |
| 147 | 176 | $this->log_fetch_hash_error('info', 'Attempting to fetch public hash from API', [ |
| 148 | 177 | 'attempt' => $attempt, |
| 149 | 178 | 'max_retries' => $max_retries, |
| @@ -185,8 +214,15 @@ | ||
| 185 | 214 | 'error_message' => $error_message, |
| 186 | 215 | 'will_retry' => $attempt < $max_retries |
| 187 | 216 | ]); |
| 188 | 217 | |
| 218 | + # A backoff block is not a transient network blip — the | |
| 219 | + # endpoint is deliberately closed for minutes. Retrying (and | |
| 220 | + # sleeping between retries) can only burn worker time. | |
| 221 | + if ($error_code === 'api_backoff_active') { | |
| 222 | + return false; | |
| 223 | + } | |
| 224 | + | |
| 189 | 225 | if ($attempt < $max_retries) { |
| 190 | 226 | $this->apply_exponential_backoff($attempt, $base_retry_delay); |
| 191 | 227 | continue; |
| 192 | 228 | } |
| @@ -224,8 +260,32 @@ | ||
| 224 | 260 | // ------------------------------------------------------------------ |
| 225 | 261 | // Public-hash helpers (private) |
| 226 | 262 | // ------------------------------------------------------------------ |
| 227 | 263 | |
| 264 | + /** | |
| 265 | + * Is $method actually available on $class in this process right now? | |
| 266 | + * | |
| 267 | + * A partially updated install can leave a newer copy of one plugin file | |
| 268 | + * beside an older copy of another — stale opcache bytecode for a single file | |
| 269 | + * is enough. class_exists() is satisfied by the older copy, and calling a | |
| 270 | + * method it does not declare is a fatal: that is how the health checks in | |
| 271 | + * fetch_public_hash() brought down the whole Dashboard admin page with | |
| 272 | + * "Call to undefined method ...::is_host_breaker_open()". | |
| 273 | + * | |
| 274 | + * $class and $method are parameters rather than literals at the call site so | |
| 275 | + * the check survives static analysis, which would otherwise narrow a literal | |
| 276 | + * method_exists() on a known class to a constant true — the skew this guards | |
| 277 | + * against exists only at runtime. | |
| 278 | + * | |
| 279 | + * @param string $class Class about to be called. | |
| 280 | + * @param string $method Method about to be called on it. | |
| 281 | + * @return bool | |
| 282 | + */ | |
| 283 | + private static function class_provides($class, $method) | |
| 284 | + { | |
| 285 | + return class_exists($class) && method_exists($class, $method); | |
| 286 | + } | |
| 287 | + | |
| 228 | 288 | private function validate_fetch_hash_inputs($uuid, $token) |
| 229 | 289 | { |
| 230 | 290 | if (empty($uuid) || empty($token)) { |
| 231 | 291 | return false; |
| @@ -332,9 +392,9 @@ | ||
| 332 | 392 | if (class_exists('Metasync_Error_Logger')) { |
| 333 | 393 | Metasync_Error_Logger::log( |
| 334 | 394 | Metasync_Error_Logger::CATEGORY_API_RATE_LIMIT, |
| 335 | 395 | Metasync_Error_Logger::SEVERITY_WARNING, |
| 336 | - 'OTTO API rate limit exceeded', | |
| 396 | + 'OTTO API throttled upstream', | |
| 337 | 397 | [ |
| 338 | 398 | 'status_code' => $status_code, |
| 339 | 399 | 'attempt' => $attempt, |
| 340 | 400 | 'will_retry' => $attempt < $max_retries, |
| @@ -439,9 +499,9 @@ | ||
| 439 | 499 | if (class_exists('Metasync_Error_Logger')) { |
| 440 | 500 | Metasync_Error_Logger::log( |
| 441 | 501 | Metasync_Error_Logger::CATEGORY_API_BACKOFF, |
| 442 | 502 | Metasync_Error_Logger::SEVERITY_INFO, |
| 443 | - 'API backoff active - applying exponential retry delay', | |
| 503 | + 'Retry scheduled - applying backoff delay', | |
| 444 | 504 | [ |
| 445 | 505 | 'attempt' => $attempt, |
| 446 | 506 | 'delay_seconds' => $delay, |
| 447 | 507 | 'base_delay' => $base_delay, |