initial
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
/**
|
||||
* Api_Client - the INSTANDA HTTP client. Basic auth, bounded transient-only retry,
|
||||
* never logs the auth header or password, and asserts the environment before any
|
||||
* mutating call so a Live call can never be made while locked to Test.
|
||||
*
|
||||
* INSTANDA has no server-side idempotency, so an ambiguous timeout on a mutating
|
||||
* call (request dispatched, response lost) is flagged - never auto-retried.
|
||||
*
|
||||
* @package ESP\Instanda
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ESP\Instanda;
|
||||
|
||||
/** Outcome of one INSTANDA call. */
|
||||
final readonly class Api_Result {
|
||||
/** @param list<string> $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<string> $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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user