Files
hub-insurance/wp-content/plugins/esp-instanda-integration/includes/class-api-client.php
T
zbatteandClaude Fable 5 0732dd662d fix(instanda): rate limiting, UTC timestamps, and close silent-failure paths
- 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>
2026-07-02 16:13:06 -06:00

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();
}
}
}