| @@ -117,8 +117,22 @@ | ||
| 117 | 117 | 'is_test' => $is_test, |
| 118 | 118 | 'shared' => $shared, |
| 119 | 119 | ]; |
| 120 | 120 | |
| 121 | + // Search Console answered with an error rather than with data. | |
| 122 | + // Every section is about to come back empty, and the no-data gate | |
| 123 | + // further down would read that as a property with no traffic: it | |
| 124 | + // would skip the period and tell the user Search Console "returned | |
| 125 | + // no data". Record it as the failure it is and retry soon, the way | |
| 126 | + // a failed send already is. Checked before the hook below so Pro | |
| 127 | + // does not build an AI summary for a report that cannot go out. | |
| 128 | + // | |
| 129 | + // A test send still goes out: it exists to prove delivery. | |
| 130 | + $fetch_error = self::fetch_error($shared); | |
| 131 | + if (!$is_test && $fetch_error !== '') { | |
| 132 | + return $this->handle_fetch_failure($config, $context, $fetch_error); | |
| 133 | + } | |
| 134 | + | |
| 121 | 135 | /** |
| 122 | 136 | * Fires before the report is rendered/sent. Pro plugin uses |
| 123 | 137 | * this to fetch + attach the AI Highlights summary. |
| 124 | 138 | * |
| @@ -282,8 +296,100 @@ | ||
| 282 | 296 | 'result' => $result, |
| 283 | 297 | 'not_connected' => true, |
| 284 | 298 | 'reason' => $reason, |
| 285 | 299 | ]; |
| 300 | + } | |
| 301 | + | |
| 302 | + /** | |
| 303 | + * The error behind a failed data fetch, or '' when the fetch worked. | |
| 304 | + * | |
| 305 | + * A fetch fails in two shapes. Email_Report_Data_Provider::fetch() | |
| 306 | + * catches a thrown API error and returns `available: false` with the | |
| 307 | + * message in `error`. Analytics_Manager::get_dashboard_data() catches | |
| 308 | + * its own and returns normally, with the message in `current.error`. | |
| 309 | + * `available: false` without an `error` is not a failure, so it is not | |
| 310 | + * reported as one. | |
| 311 | + * | |
| 312 | + * @since 2.15.0 | |
| 313 | + * | |
| 314 | + * @param array $shared The data provider's fetch() result. | |
| 315 | + * @return string The error message, or '' when nothing failed. | |
| 316 | + */ | |
| 317 | + private static function fetch_error(array $shared): string { | |
| 318 | + if (empty($shared['available']) && !empty($shared['error']) && is_scalar($shared['error'])) { | |
| 319 | + return (string) $shared['error']; | |
| 320 | + } | |
| 321 | + | |
| 322 | + $current = is_array($shared['current'] ?? null) ? $shared['current'] : []; | |
| 323 | + if (!empty($current['error']) && is_scalar($current['error'])) { | |
| 324 | + return (string) $current['error']; | |
| 325 | + } | |
| 326 | + | |
| 327 | + return ''; | |
| 328 | + } | |
| 329 | + | |
| 330 | + /** | |
| 331 | + * A scheduled run whose Search Console fetch failed. | |
| 332 | + * | |
| 333 | + * Treated like a failed send, not like a period with no data: the | |
| 334 | + * period's log row is claimed and closed as `failed` with the API's | |
| 335 | + * message, the next attempt is booked RETRY_DELAY_HOURS out, and once | |
| 336 | + * MAX_SEND_ATTEMPTS are spent the schedule rejoins the normal cadence. | |
| 337 | + * The reason and the message are recorded for the panel. | |
| 338 | + * | |
| 339 | + * @since 2.15.0 | |
| 340 | + * | |
| 341 | + * @param array $config Per-site config. | |
| 342 | + * @param array $context Render context (period bounds). | |
| 343 | + * @param string $error The fetch error. | |
| 344 | + * @return array{success:bool,skipped:string,error?:string} | |
| 345 | + */ | |
| 346 | + private function handle_fetch_failure(array $config, array $context, string $error): array { | |
| 347 | + $frequency = (int) ($config['frequency_days'] ?? 30); | |
| 348 | + $message = self::short_error($error); | |
| 349 | + | |
| 350 | + $dedupe = $this->record_attempt($config, $context); | |
| 351 | + if (!$dedupe['inserted']) { | |
| 352 | + if (!empty($dedupe['exhausted'])) { | |
| 353 | + $this->config->update_schedule( | |
| 354 | + $this->config->get()['last_sent_at'] ?? null, | |
| 355 | + $this->compute_next_run($frequency) | |
| 356 | + ); | |
| 357 | + $this->config->record_skip('fetch_failed', $message); | |
| 358 | + return ['success' => false, 'skipped' => 'retry_limit', 'error' => $message]; | |
| 359 | + } | |
| 360 | + return [ | |
| 361 | + 'success' => false, | |
| 362 | + 'skipped' => empty($dedupe['write_failed']) ? 'duplicate' : 'log_write_failed', | |
| 363 | + ]; | |
| 364 | + } | |
| 365 | + | |
| 366 | + $this->finalize_log($config, $context, ['success' => false, 'error' => $message]); | |
| 367 | + | |
| 368 | + $attempts = (int) ($dedupe['attempts'] ?? 1); | |
| 369 | + $this->config->update_schedule( | |
| 370 | + $this->config->get()['last_sent_at'] ?? null, | |
| 371 | + $attempts >= self::MAX_SEND_ATTEMPTS | |
| 372 | + ? $this->compute_next_run($frequency) | |
| 373 | + : $this->compute_retry_run() | |
| 374 | + ); | |
| 375 | + $this->config->record_skip('fetch_failed', $message); | |
| 376 | + | |
| 377 | + return ['success' => false, 'skipped' => 'fetch_failed', 'error' => $message]; | |
| 378 | + } | |
| 379 | + | |
| 380 | + /** | |
| 381 | + * An error message fit for the log row and the panel: plain text, one | |
| 382 | + * line, at most 300 characters. | |
| 383 | + * | |
| 384 | + * @since 2.15.0 | |
| 385 | + * | |
| 386 | + * @param string $error Raw error message. | |
| 387 | + * @return string | |
| 388 | + */ | |
| 389 | + private static function short_error(string $error): string { | |
| 390 | + $error = trim((string) preg_replace('/\s+/', ' ', wp_strip_all_tags($error))); | |
| 391 | + return mb_strlen($error) > 300 ? rtrim(mb_substr($error, 0, 299)) . '…' : $error; | |
| 286 | 392 | } |
| 287 | 393 | |
| 288 | 394 | /** |
| 289 | 395 | * Claim this period's send by inserting a `pending` row in |