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>
This commit is contained in:
@@ -23,6 +23,9 @@ final class Submission {
|
||||
private const RESULT_TTL = 120;
|
||||
private const STASH_TTL = 300;
|
||||
public const DEDUPE_WINDOW = 1800; // 30 min - collapse identical resubmits to the existing quote
|
||||
private const RL_IP_MAX = 20; // valid submissions per IP per hour
|
||||
private const RL_EMAIL_MAX = 5; // valid submissions per email per hour
|
||||
private const RL_WINDOW = 3600; // rate-limit window (seconds)
|
||||
|
||||
/** Wire var key => feed meta key (the mapped GF field id is stored under each). */
|
||||
private const FIELD_KEYS = [
|
||||
@@ -56,7 +59,21 @@ final class Submission {
|
||||
return $validation_result; // GATE 3 - only act on the final page
|
||||
}
|
||||
|
||||
$token = $this->submission_token($form);
|
||||
// Fail CLOSED for this one submission if the create path throws (no quote-less
|
||||
// entry saved), keep the form working for everyone else, and surface the failure.
|
||||
try {
|
||||
return $this->process($validation_result, $form, $feed);
|
||||
} catch (\Throwable $e) {
|
||||
Guard::report($e);
|
||||
$this->record_hard_failure($e);
|
||||
$this->pending_message = 'We could not start your quote just now. Please try again.';
|
||||
return $this->mark_form_invalid($validation_result);
|
||||
}
|
||||
}
|
||||
|
||||
/** The gated INSTANDA create - runs only after GATE 1-3 pass, inside validate()'s guard. */
|
||||
private function process(array $validation_result, array $form, array $feed): array {
|
||||
$token = $this->submission_token($form);
|
||||
if (!$this->acquire_lock($token)) {
|
||||
return $this->replay($token, $validation_result); // GATE 4 - within-request double-fire
|
||||
}
|
||||
@@ -89,6 +106,13 @@ final class Submission {
|
||||
return $this->remember($token, $validation_result, false);
|
||||
}
|
||||
|
||||
// Abuse control: throttle the paid API + PII table per IP and per email.
|
||||
$throttle = $this->rate_limit_message((string) ($values['customer_email'] ?? ''));
|
||||
if ($throttle !== null) {
|
||||
$this->pending_message = $throttle;
|
||||
return $this->remember($token, $this->mark_form_invalid($validation_result), false);
|
||||
}
|
||||
|
||||
$body = $mapper->to_payload($values, $tz);
|
||||
|
||||
// Write-ahead: persist BEFORE the HTTP call (redacted body carries no secrets).
|
||||
@@ -103,35 +127,47 @@ final class Submission {
|
||||
$tx->role_label = (string) ($values['role'] ?? '');
|
||||
$tx->redacted_payload = (string) wp_json_encode($body);
|
||||
$tx_id = $store->create_pending($tx);
|
||||
|
||||
// A failed write-ahead insert means we cannot audit the call - do not send it.
|
||||
if ($tx_id === 0) {
|
||||
Logger::error('Write-ahead insert failed; aborting StartQuote to avoid an unaudited call.');
|
||||
$this->alert_failure($this->synth_tx($form, $values, $dedupe, 'Write-ahead insert failed - submission not sent.'), null);
|
||||
Failure_Surface::bump();
|
||||
$this->pending_message = $this->friendly_failure_message(true);
|
||||
return $this->remember($token, $this->mark_form_invalid($validation_result), false);
|
||||
}
|
||||
}
|
||||
|
||||
$result = (new Api_Client())->start_quote($body, $dedupe);
|
||||
$result = (new Api_Client())->start_quote($body, $dedupe);
|
||||
$has_ref = $result->quote_ref !== null && $result->quote_ref !== '';
|
||||
|
||||
if ($result->ok && $result->quote_ref !== null) {
|
||||
if ($result->ok && $has_ref) {
|
||||
if ($tx_id) {
|
||||
$store->mark_success($tx_id, $result->quote_ref, $result->url_single_use, $result->http_status);
|
||||
$store->mark_success($tx_id, (string) $result->quote_ref, $result->url_single_use, $result->http_status);
|
||||
}
|
||||
$this->stash_success($dedupe, $result->quote_ref, $result->url_single_use);
|
||||
$this->stash_success($dedupe, (string) $result->quote_ref, $result->url_single_use);
|
||||
return $this->remember($token, $validation_result, true);
|
||||
}
|
||||
|
||||
// Failure / ambiguous timeout - input preserved, no entry saved.
|
||||
// Failure / ambiguous - input preserved, no entry saved. A 2xx without a usable
|
||||
// quoteRef is ambiguous too: INSTANDA may have created a quote we cannot see.
|
||||
$ambiguous = $result->is_ambiguous || ($result->ok && !$has_ref);
|
||||
if ($tx_id) {
|
||||
if ($result->is_ambiguous) {
|
||||
if ($ambiguous) {
|
||||
$store->mark_ambiguous($tx_id, $result->http_status, $result->error_string());
|
||||
} else {
|
||||
$store->mark_failed($tx_id, $result->http_status, $result->response_excerpt, $result->error_string());
|
||||
}
|
||||
$stored = $store->find($tx_id);
|
||||
if ($stored !== null) {
|
||||
(new Notifier())->validation_failure_alert($stored, $result);
|
||||
}
|
||||
$stored = $store->find($tx_id) ?? $this->synth_tx($form, $values, $dedupe, $result->error_string());
|
||||
} else {
|
||||
// Storage disabled - still surface the failure (no silent drop).
|
||||
$stored = $this->synth_tx($form, $values, $dedupe, $result->error_string());
|
||||
}
|
||||
$this->alert_failure($stored, $result);
|
||||
Failure_Surface::bump();
|
||||
|
||||
$this->pending_message = $this->friendly_failure_message($result);
|
||||
$invalid = $this->mark_form_invalid($validation_result);
|
||||
return $this->remember($token, $invalid, false);
|
||||
$this->pending_message = $this->friendly_failure_message($ambiguous);
|
||||
return $this->remember($token, $this->mark_form_invalid($validation_result), false);
|
||||
}
|
||||
|
||||
/** gform_validation_message filter - surface our form-level failure message. */
|
||||
@@ -335,10 +371,62 @@ final class Submission {
|
||||
unset($field);
|
||||
}
|
||||
|
||||
private function friendly_failure_message(Api_Result $result): string {
|
||||
if ($result->is_ambiguous) {
|
||||
private function friendly_failure_message(bool $ambiguous): string {
|
||||
if ($ambiguous) {
|
||||
return 'We could not confirm your quote. Please try again in a moment - we have saved your details.';
|
||||
}
|
||||
return 'We could not start your quote just now. Please review your entries and try again.';
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- abuse control */
|
||||
|
||||
/** A friendly message when over the per-IP or per-email hourly limit, else null. */
|
||||
private function rate_limit_message(string $email): ?string {
|
||||
$ip = isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '';
|
||||
if ($ip !== '' && $this->bump_counter('rl_ip_' . md5($ip)) > self::RL_IP_MAX) {
|
||||
return 'Too many quote attempts from your connection. Please try again later.';
|
||||
}
|
||||
$email = strtolower(trim($email));
|
||||
if ($email !== '' && $this->bump_counter('rl_email_' . md5($email)) > self::RL_EMAIL_MAX) {
|
||||
return 'You have started several quotes recently. Please try again later or contact us for help.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function bump_counter(string $key): int {
|
||||
$tkey = 'espinstanda_' . $key;
|
||||
$n = (int) get_transient($tkey) + 1;
|
||||
set_transient($tkey, $n, self::RL_WINDOW);
|
||||
return $n;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- failure surfacing */
|
||||
|
||||
private function alert_failure(Transaction $tx, ?Api_Result $result): void {
|
||||
(new Notifier())->validation_failure_alert($tx, $result);
|
||||
}
|
||||
|
||||
/** In-memory transaction used to alert when no row was stored (storage off / insert failed). */
|
||||
private function synth_tx(array $form, array $values, string $dedupe, string $error): Transaction {
|
||||
$tx = new Transaction();
|
||||
$tx->form_id = (int) ($form['id'] ?? 0);
|
||||
$tx->environment = Config::environment()->value;
|
||||
$tx->dedupe_key = $dedupe;
|
||||
$tx->customer_email = (string) ($values['customer_email'] ?? '');
|
||||
$tx->role_label = (string) ($values['role'] ?? '');
|
||||
$tx->error_detail = $error;
|
||||
return $tx;
|
||||
}
|
||||
|
||||
/** Best-effort admin alert when process() itself throws (F-11). Never throws. */
|
||||
private function record_hard_failure(\Throwable $e): void {
|
||||
try {
|
||||
$tx = new Transaction();
|
||||
$tx->environment = Config::environment()->value;
|
||||
$tx->error_detail = 'Unexpected error during quote submission: ' . $e->getMessage();
|
||||
(new Notifier())->validation_failure_alert($tx);
|
||||
} catch (\Throwable $ignored) {
|
||||
// never let the failure path itself throw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user