- F-06: throttle the public create per IP (20/h) and per email (5/h) before the paid API call and the PII write. - F-11: wrap the create path in try/catch - on an unexpected error fail closed for that submission (no quote-less entry), alert admins, keep the form working. - F-14: abort the StartQuote when the write-ahead insert fails (no unaudited call); fire the failure alert even when storage is disabled (build an in-memory tx). - F-10/F-17: treat a 2xx with a missing/empty quoteRef as ambiguous (possible_duplicate=1), not a hard failure - a quote may exist. - F-07: write created_at/delivered_at/event timestamps in UTC to match the gmdate() retention/dedupe cutoffs; display in site-local via get_date_from_gmt. - F-09: log (no silent return) when async record_feed_result cannot resolve a delivered transaction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
201 lines
7.8 KiB
PHP
201 lines
7.8 KiB
PHP
<?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) {
|
|
// An empty-string quoteRef is not usable - treat it as missing so the caller
|
|
// handles the 2xx-without-quoteRef case as ambiguous, not as a delivered quote.
|
|
$ref = isset($data['quoteRef']) && $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();
|
|
}
|
|
}
|
|
}
|