*/ private static array $memory = []; public function __construct(private int $ttlSeconds = 900) { $this->ttlSeconds = max(1, $ttlSeconds); } public function canExecute(string $provider): bool { return $this->state($provider) === 'CLOSED'; } /** * Structured status/HTTP metadata wins; message parsing is a final fallback. * @param array $metadata */ public function record(string $provider, string $status, ?string $message = null, array $metadata = []): void { $failureClass = $this->failureClass($status, $metadata, $message); if ($failureClass === null) { return; } $policy = $this->metadata($provider); if ($failureClass === 'TIMEOUT' && empty($policy['timeout_cooldown_seconds'])) { return; } $ttl = $failureClass === 'QUOTA_EXHAUSTED' ? max(21600, $this->ttlSeconds) : $this->ttlSeconds; if ($failureClass === 'RATE_LIMITED' && !empty($policy['rate_limit_cooldown_seconds'])) { $ttl = max(1,(int)$policy['rate_limit_cooldown_seconds']); } if ($failureClass === 'TIMEOUT') { $ttl = max(1,(int)$policy['timeout_cooldown_seconds']); } $payload = ['state' => $failureClass, 'until' => time() + $ttl, 'opened_at' => time(), 'observed_status' => in_array(strtoupper($status), self::FAILURE_CLASSES, true) ? strtoupper($status) : 'OTHER', 'http_status' => max(0, (int)($metadata['http_status'] ?? $metadata['status_code'] ?? 0))]; $key = $this->key($provider, $failureClass); self::$memory[$key] = $payload; if (function_exists('set_transient')) { set_transient($key, $payload, $ttl); } } public function state(string $provider): string { foreach (self::FAILURE_CLASSES as $failureClass) { $key = $this->key($provider, $failureClass); $payload = $this->read($key); if ($payload === null) { continue; } if (($payload['until'] ?? 0) > time()) { return (string) $payload['state']; } unset(self::$memory[$key]); if (function_exists('delete_transient')) { delete_transient($key); } } return 'CLOSED'; } public function details(string $provider): array { foreach (self::FAILURE_CLASSES as $class) { $payload = $this->read($this->key($provider, $class)); if ($payload === null || (int)($payload['until'] ?? 0) <= time()) { continue; } return ['state'=>$class, 'until'=>(int)$payload['until'], 'remaining_seconds'=>max(0, (int)$payload['until']-time()), 'opened_at'=>isset($payload['opened_at']) ? (int)$payload['opened_at'] : null, 'observed_status'=>isset($payload['observed_status']) && in_array($payload['observed_status'], array_merge(self::FAILURE_CLASSES,['OTHER']), true) ? $payload['observed_status'] : 'not_recorded', 'http_status'=>max(0, (int)($payload['http_status'] ?? 0))]; } return ['state'=>'CLOSED', 'remaining_seconds'=>0]; } public function metadata(string $provider): array { return RuntimeProviderCatalog::executionMetadata($provider); } /** @param array $metadata */ private function failureClass(string $status, array $metadata, ?string $message): ?string { $normalized = strtoupper(trim($status)); if (in_array($normalized, [ProviderStatus::RATE_LIMITED, 'QUOTA_EXHAUSTED'], true)) { return $normalized === 'QUOTA_EXHAUSTED' ? 'QUOTA_EXHAUSTED' : 'RATE_LIMITED'; } if ($normalized === ProviderStatus::TIMEOUT) { return 'TIMEOUT'; } if ($normalized === ProviderStatus::AUTH_ERROR) { return 'AUTH_ERROR'; } $structuredFailure = strtoupper(trim((string) ($metadata['failure_class'] ?? ''))); if (in_array($structuredFailure, self::FAILURE_CLASSES, true)) { return $structuredFailure; } $httpStatus = (int) ($metadata['http_status'] ?? $metadata['status_code'] ?? 0); if ($httpStatus === 429) { return 'RATE_LIMITED'; } if (in_array($httpStatus, [401, 403], true)) { return 'AUTH_ERROR'; } $text = strtolower((string) $message); if (str_contains($text, 'quota exhausted') || str_contains($text, 'quota esaurita')) { return 'QUOTA_EXHAUSTED'; } if (str_contains($text, 'rate limit') || preg_match('/\b429\b/', $text) === 1) { return 'RATE_LIMITED'; } if (str_contains($text, 'unauthor') || str_contains($text, 'forbidden') || preg_match('/\b(401|403)\b/', $text) === 1) { return 'AUTH_ERROR'; } return null; } /** @return array{state:string,until:int}|null */ private function read(string $key): ?array { if (function_exists('get_transient')) { $value = get_transient($key); if (is_array($value)) { return $value; } } return self::$memory[$key] ?? null; } private function key(string $provider, string $failureClass): string { return self::PREFIX . substr(hash('sha256', strtolower(trim($provider)) . '|' . $failureClass), 0, 32); } }