$error_messages */ public function __construct( public bool $ok, public ?int $http_status, public ?string $quote_ref, public ?string $url_single_use, public array $error_messages, public bool $is_transient, public bool $is_ambiguous, public string $response_excerpt, ) {} public static function success(int $status, ?string $ref, ?string $url, string $excerpt): self { return new self(true, $status, $ref, $url, [], false, false, $excerpt); } /** @param list $errors */ public static function failure( ?int $status, array $errors, string $excerpt, bool $transient = false, bool $ambiguous = false ): self { return new self(false, $status, null, null, $errors, $transient, $ambiguous, $excerpt); } public function error_string(): string { if ($this->error_messages) { return implode('; ', $this->error_messages); } if ($this->http_status !== null) { return 'HTTP ' . $this->http_status; } return $this->is_ambiguous ? 'Ambiguous timeout' : 'Request failed'; } } final class Api_Client { private const TIMEOUT = 8; // seconds private const MAX_ATTEMPTS = 2; // 1 try + 1 retry private const RETRY_BUDGET_SEC = 3.0; private const BACKOFF_USEC = 800000; private const ALERT_401_AFTER = 3; /** PUT /StartQuote - the one mutating create. */ public function start_quote(array $body, string $dedupe_key): Api_Result { Logger::debug("StartQuote dispatch (dedupe {$dedupe_key})"); $url = Config::base_url() . 'StartQuote?siteDomainName=' . rawurlencode(Config::site_domain()); return $this->request('PUT', $url, null, $body, true); } /** GET /ContinueQuote - re-mint a fresh single-use URL from a stored quoteRef. */ public function continue_quote(string $quote_ref): Api_Result { $url = Config::base_url() . 'ContinueQuote?' . 'quoteRef=' . rawurlencode($quote_ref) . '&siteDomainName=' . rawurlencode(Config::site_domain()) . '&targetSaleStage=Quote'; return $this->request('GET', $url, null, null, true); } /** * GET /swagger - read-only auth + reachability probe. Creates nothing. * Accepts an explicit environment so Live credentials can be tested while the * plugin is still locked to Test (the only call allowed to target a non-active env). */ public function test_connection(?Environment $env = null, ?Credentials $cred = null): Api_Result { $env = $env ?? Config::environment(); $url = $env->base_url() . 'swagger'; return $this->request('GET', $url, $env, null, false, $cred); } /* ----------------------------------------------------------------- core */ private function request(string $method, string $url, ?Environment $env, ?array $body, bool $mutating, ?Credentials $cred = null): Api_Result { $env = $env ?? Config::environment(); // Outbound assertion (defense in depth): never make a mutating Live call while locked. if ($mutating && Config::operating_mode() === 'test' && $env === Environment::Live) { throw new \RuntimeException('Blocked a mutating Live call while operating mode is locked to Test.'); } $cred = $cred ?? Config::credentials($env); $args = [ 'method' => $method, 'timeout' => self::TIMEOUT, 'headers' => [ 'Authorization' => $cred->basic_auth_header(), 'Accept' => 'application/json', ], ]; if ($body !== null) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = wp_json_encode($body); } $attempts = 0; $started = microtime(true); $response = null; $transient = false; do { $attempts++; $response = wp_remote_request($url, $args); $transient = false; if (!is_wp_error($response)) { $code = (int) wp_remote_retrieve_response_code($response); if ($code < 500) { break; // 2xx or 4xx - deterministic, never retry } $transient = true; // 5xx - retryable } else { $transient = true; // transport/timeout - retryable } if ($attempts >= self::MAX_ATTEMPTS || (microtime(true) - $started) > self::RETRY_BUDGET_SEC) { break; } Logger::debug("Transient error - retry {$attempts}/" . (self::MAX_ATTEMPTS - 1)); usleep(self::BACKOFF_USEC); } while (true); return $this->interpret($response, $mutating, $transient); } private function interpret(mixed $response, bool $mutating, bool $transient): Api_Result { // Transport error / timeout: a mutating call is ambiguous (may have been delivered). if (is_wp_error($response)) { $msg = Logger::redact($response->get_error_message()); Logger::error("INSTANDA transport error: {$msg}"); return Api_Result::failure(null, [$msg], $msg, true, $mutating); } $code = (int) wp_remote_retrieve_response_code($response); $raw = (string) wp_remote_retrieve_body($response); $excerpt = Logger::redact(substr($raw, 0, 500)); $data = json_decode($raw, true); $data = is_array($data) ? $data : []; if ($code >= 200 && $code < 300) { $ref = isset($data['quoteRef']) ? (string) $data['quoteRef'] : null; $url = isset($data['urlSingleUse']) ? (string) $data['urlSingleUse'] : null; return Api_Result::success($code, $ref, $url, $excerpt); } if ($code === 401) { $this->note_401(); } $errors = $this->parse_errors($data); if (!$errors) { $errors = ["HTTP {$code}"]; } Logger::error("INSTANDA HTTP {$code}: " . implode('; ', $errors)); // 5xx after retries on a mutating call is ambiguous; 4xx is deterministic. $ambiguous = $mutating && $code >= 500; return Api_Result::failure($code, $errors, $excerpt, $transient, $ambiguous); } /** Handle both StartQuote ("Error Messages") and AbandonQuote ("Errors") envelopes. */ private function parse_errors(array $data): array { foreach (['Error Messages', 'Errors', 'errors'] as $key) { if (isset($data[$key]) && is_array($data[$key])) { return array_map(static fn ($e) => (string) $e, $data[$key]); } } return []; } private function note_401(): void { $count = (int) get_transient('espinstanda_401_count') + 1; set_transient('espinstanda_401_count', $count, HOUR_IN_SECONDS); if ($count >= self::ALERT_401_AFTER && class_exists(__NAMESPACE__ . '\\Notifier')) { (new Notifier())->credential_failure_alert(); } } }