From aa4997df9ec028a916a38057ddf4e0e283aaa4b6 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Fri, 2 Oct 2026 11:57:03 -0500 Subject: [PATCH] fix: never make a page wait on WebDecoy Cloud when ingest is slow or down Detections were sent inline during init, before the block decision, with a 10 second timeout, and installs without a stored organization id made a second 10 second key lookup first. When ingest was slow or down, every flagged page view stalled for up to 20 seconds (WebDecoy/app#1245). - Detections are queued during the request and sent from a shutdown handler after fastcgi_finish_request(), with a 3 second timeout and the stored organization id, so one send is one request. - A refusal (429, 5xx, no answer) pauses cloud calls for 60 seconds across all requests, so an outage costs one attempt per minute, not one per page. - IP enrichment, which filter rules need in the page path, skips the call while paused and starts the pause on an unavailable answer. - Tests pin the deferral, the backoff and, by reading the plugin source, that the only detection send is inside the deferred sender. --- includes/class-webdecoy-detection-sender.php | 154 +++++++++++++++++++ includes/class-webdecoy-ip-enrichment.php | 21 ++- tests/DetectionSenderTest.php | 86 +++++++++++ tests/IpEnrichmentBackoffTest.php | 53 +++++++ webdecoy.php | 46 +++++- 5 files changed, 355 insertions(+), 5 deletions(-) create mode 100644 includes/class-webdecoy-detection-sender.php create mode 100644 tests/DetectionSenderTest.php create mode 100644 tests/IpEnrichmentBackoffTest.php diff --git a/includes/class-webdecoy-detection-sender.php b/includes/class-webdecoy-detection-sender.php new file mode 100644 index 0000000..2c2d5a7 --- /dev/null +++ b/includes/class-webdecoy-detection-sender.php @@ -0,0 +1,154 @@ + */ + private static $queue = []; + + /** @var bool */ + private static $registered = false; + + /** + * Test seam: replaces the transient store. Null uses WordPress transients. + * + * @var array|null + */ + public static $store = null; + + /** + * Queue one send for after the response. $send performs the request and + * throws on failure. + * + * @param callable():void $send + */ + public static function defer(callable $send): void + { + if (self::backing_off() || count(self::$queue) >= self::MAX_PER_REQUEST) { + return; + } + self::$queue[] = $send; + if (!self::$registered) { + self::$registered = true; + register_shutdown_function([self::class, 'flush']); + } + } + + /** + * Shutdown handler: release the visitor first, then send. + */ + public static function flush(): void + { + if (self::$queue === []) { + return; + } + if (function_exists('fastcgi_finish_request')) { + @fastcgi_finish_request(); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + } + self::send_queued(); + } + + /** + * Send everything queued, stopping at the first refusal. Separate from + * flush() so tests can run it without finishing the request. + */ + public static function send_queued(): void + { + $queue = self::$queue; + self::$queue = []; + foreach ($queue as $send) { + if (self::backing_off()) { + return; + } + try { + $send(); + } catch (\Throwable $e) { + if (self::is_refusal($e)) { + self::note_refusal(); + } + error_log('WebDecoy API error: ' . $e->getMessage()); + } + } + } + + /** Whether ingest is currently refusing work. */ + public static function backing_off(): bool + { + if (self::$store !== null) { + return !empty(self::$store[self::BACKOFF_TRANSIENT]); + } + return (bool) get_transient(self::BACKOFF_TRANSIENT); + } + + /** + * Record that ingest refused work, pausing every cloud call that checks + * backing_off() (detection sends here, IP enrichment) for BACKOFF_SECONDS. + * The one answer to "is ingest refusing right now" for the plugin. + */ + public static function note_refusal(): void + { + if (self::$store !== null) { + self::$store[self::BACKOFF_TRANSIENT] = 1; + return; + } + set_transient(self::BACKOFF_TRANSIENT, 1, self::BACKOFF_SECONDS); + } + + /** + * A refusal means "come back later": rate limited (429), shedding or down + * (5xx), or no HTTP answer at all (code 0). A 4xx other than 429 is an + * answer about this request, not about ingest, and does not pause sending. + */ + public static function is_refusal(\Throwable $e): bool + { + $code = (int) $e->getCode(); + return $code === 0 || $code === 429 || $code >= 500; + } + + /** Test seam: forget queued sends and registration. */ + public static function reset(): void + { + self::$queue = []; + self::$registered = false; + self::$store = []; + } + + /** Test seam: how many sends are queued. */ + public static function queued(): int + { + return count(self::$queue); + } +} diff --git a/includes/class-webdecoy-ip-enrichment.php b/includes/class-webdecoy-ip-enrichment.php index 96022bd..9d7d0e6 100644 --- a/includes/class-webdecoy-ip-enrichment.php +++ b/includes/class-webdecoy-ip-enrichment.php @@ -79,6 +79,12 @@ public function enrich(string $ip): ?array return null; } + // While ingest is refusing work, skip the call instead of making this + // page wait out the timeout for every new IP (WebDecoy/app#1245). + if (class_exists('WebDecoy_Detection_Sender') && WebDecoy_Detection_Sender::backing_off()) { + return null; + } + $data = $this->fetch($ip); if ($data === null) { @@ -110,9 +116,14 @@ private function fetch(string $ip): ?array ]); if (is_wp_error($response)) { + self::note_refusal(); return null; } - if ((int) wp_remote_retrieve_response_code($response) !== 200) { + $code = (int) wp_remote_retrieve_response_code($response); + if ($code === 429 || $code >= 500) { + self::note_refusal(); + } + if ($code !== 200) { return null; } @@ -125,4 +136,12 @@ private function fetch(string $ip): ?array return $data; } + + /** Pause cloud calls after an unavailable answer (WebDecoy/app#1245). */ + private static function note_refusal(): void + { + if (class_exists('WebDecoy_Detection_Sender')) { + WebDecoy_Detection_Sender::note_refusal(); + } + } } diff --git a/tests/DetectionSenderTest.php b/tests/DetectionSenderTest.php new file mode 100644 index 0000000..967f375 --- /dev/null +++ b/tests/DetectionSenderTest.php @@ -0,0 +1,86 @@ +submitDetection('), 'exactly one send site'); + $send = strpos($src, '->submitDetection('); + $defer = strrpos(substr($src, 0, (int) $send), 'WebDecoy_Detection_Sender::defer('); + $true($defer !== false && $send - $defer < 400, 'the send site is inside a WebDecoy_Detection_Sender::defer closure'); + // The detection client never falls back to a per-request key lookup when + // the organization id is known, and never waits 10 seconds. + $true(strpos($src, "'timeout' => 3,") !== false, 'detection client uses a short timeout'); + $true(strpos($src, "'organization_id' => \$org !== '' ? \$org : null,") !== false, 'detection client is given the organization id'); +}); + diff --git a/tests/IpEnrichmentBackoffTest.php b/tests/IpEnrichmentBackoffTest.php new file mode 100644 index 0000000..8d18d46 --- /dev/null +++ b/tests/IpEnrichmentBackoffTest.php @@ -0,0 +1,53 @@ + 0, 'code' => 503]; + function wp_remote_get($url, $args = []) { $GLOBALS['wd_test_remote']['calls']++; return ['code' => $GLOBALS['wd_test_remote']['code']]; } + function is_wp_error($thing) { return false; } + function wp_remote_retrieve_response_code($response) { return $response['code']; } + function wp_remote_retrieve_body($response) { return ''; } +} +require_once dirname(__DIR__) . '/includes/class-webdecoy-ip-enrichment.php'; + +$t('IP enrichment makes no call while ingest is refusing, and a 503 starts the pause', function () use ($same, $true) { + WebDecoy_Detection_Sender::reset(); + $GLOBALS['wd_test_remote'] = ['calls' => 0, 'code' => 503]; + $enricher = new WebDecoy_IP_Enrichment('key'); + $enricher->enrich('198.51.100.7'); + $same(1, $GLOBALS['wd_test_remote']['calls'], 'first call is made'); + $true(WebDecoy_Detection_Sender::backing_off(), 'a 503 pauses cloud calls'); + $enricher->enrich('198.51.100.8'); + $enricher->enrich('198.51.100.9'); + $same(1, $GLOBALS['wd_test_remote']['calls'], 'no further calls while paused'); + WebDecoy_Detection_Sender::reset(); +}); diff --git a/webdecoy.php b/webdecoy.php index b2fc31f..9e7d2c2 100644 --- a/webdecoy.php +++ b/webdecoy.php @@ -1046,6 +1046,7 @@ public function load_includes(): void require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-pow.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-behavioral-scorer.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-violation-reporter.php'; + require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-detection-sender.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-ai-referrals.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-honeytoken.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-ip-enrichment.php'; @@ -1209,7 +1210,8 @@ public function early_check(): void // Always log detection locally $this->log_detection($result, $ip); - // Submit to API (fail open) + // Queue for WebDecoy Cloud. Sent after the response, never in + // front of the block decision below (WebDecoy/app#1245). try { $this->submit_detection($result, $ip); } catch (\Exception $e) { @@ -1932,8 +1934,7 @@ private function submit_detection(\WebDecoy\DetectionResult $result, string $ip) return; } - $client = $this->get_client(); - if (!$client) { + if (empty($this->options['api_key']) || WebDecoy_Detection_Sender::backing_off()) { return; } @@ -1952,7 +1953,44 @@ private function submit_detection(\WebDecoy\DetectionResult $result, string $ip) 'metadata' => $result->getMetadata(), ]); - $client->submitDetection($detection); + // Built now, while the request is in hand; sent at shutdown, after the + // visitor has their response (WebDecoy/app#1245). + WebDecoy_Detection_Sender::defer(function () use ($detection): void { + $client = $this->get_detection_client(); + if (!$client) { + return; + } + $client->submitDetection($detection); + if (empty($this->options['organization_id'])) { + set_transient('webdecoy_detect_org_id', $client->getOrganizationId(), DAY_IN_SECONDS); + } + }); + } + + /** + * The client detections are sent with (WebDecoy/app#1245): a 3 second + * timeout, and the organization id passed in so a send is one request, + * not a key lookup followed by the detection. + */ + private function get_detection_client(): ?\WebDecoy\Client + { + if (empty($this->options['api_key'])) { + return null; + } + $org = (string) ($this->options['organization_id'] ?? ''); + if ($org === '') { + $org = (string) get_transient('webdecoy_detect_org_id'); + } + try { + return new \WebDecoy\Client([ + 'api_key' => $this->options['api_key'], + 'organization_id' => $org !== '' ? $org : null, + 'timeout' => 3, + ]); + } catch (\Exception $e) { + error_log('WebDecoy client error: ' . $e->getMessage()); + return null; + } } /**