From df557e419b488d69cd3ff193303e2b0bc2e3bbd9 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 6 Oct 2026 13:12:29 +0000 Subject: [PATCH] =?UTF-8?q?search-console:=20vor=C3=BCbergehende=20Google-?= =?UTF-8?q?Fehler=20einmal=20wiederholt=20und=20nur=20als=20Warnung=20prot?= =?UTF-8?q?okolliert,=20Portal-Abbruch=20nach=20f=C3=BCnf=20Fehlern=20in?= =?UTF-8?q?=20Folge,=20Zeitbudget=2040=20Minuten=20(GlitchTip=20server-pre?= =?UTF-8?q?ssekonto=2011=E2=80=9314)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Am 06.10.2026 hing die URL Inspection API zeitweise: 28 Zeitüberschreitungen und 4 × 500, jede einzeln als Fehler gemeldet, der Lauf zog sich über 1¾ Stunden. Neu: SearchConsoleUnavailable für Timeout/5xx/429 (Warnung, Meldung bleibt fällig), echte Fehler (401/403/400) weiter über report(); FAILURE — und damit ein Betriebs-Alert — nur bei Abbruch oder ≥ 50 % Fehlern. Co-Authored-By: Claude Opus 5.5 --- app/Console/Commands/CheckIndexStatus.php | 52 ++++++-- .../SearchConsole/SearchConsoleClient.php | 49 +++++-- .../SearchConsoleUnavailable.php | 14 ++ config/search_console.php | 12 ++ tests/Feature/IndexStatusCheckTest.php | 120 ++++++++++++++++++ 5 files changed, 228 insertions(+), 19 deletions(-) create mode 100644 app/Services/SearchConsole/SearchConsoleUnavailable.php diff --git a/app/Console/Commands/CheckIndexStatus.php b/app/Console/Commands/CheckIndexStatus.php index 426243dc..8262c498 100644 --- a/app/Console/Commands/CheckIndexStatus.php +++ b/app/Console/Commands/CheckIndexStatus.php @@ -5,7 +5,9 @@ namespace App\Console\Commands; use App\Enums\Portal; use App\Services\SearchConsole\IndexStatusChecker; use App\Services\SearchConsole\SearchConsoleClient; +use App\Services\SearchConsole\SearchConsoleUnavailable; use Illuminate\Console\Command; +use Illuminate\Support\Facades\Log; /** * Indexierungsstatus über die Search Console abfragen (Roadmap 1c.4). @@ -50,10 +52,14 @@ class CheckIndexStatus extends Command $checked = 0; $failed = 0; + $aborted = false; + $deadline = now()->addMinutes((int) config('search_console.max_runtime_minutes', 40)); + $maxConsecutive = max(1, (int) config('search_console.max_consecutive_failures', 5)); foreach ($portals as $portal) { $candidates = $checker->candidates($portal, $budget); $this->info(sprintf('%s: %d fällige Meldungen (Kontingent %d).', $portal->label(), $candidates->count(), $budget)); + $consecutiveFailures = 0; foreach ($candidates as $release) { if ($dryRun) { @@ -62,28 +68,56 @@ class CheckIndexStatus extends Command continue; } + // Zeitbudget unter der Overlap-Sperre: was übrig bleibt, ist + // morgen weiter fällig. + if (now()->greaterThanOrEqualTo($deadline)) { + $this->warn('Zeitbudget erreicht — restliche Meldungen beim nächsten Lauf.'); + Log::warning('Search Console: Zeitbudget des Laufs erreicht.', ['checked' => $checked, 'failed' => $failed]); + + break 2; + } + try { $check = $checker->check($release, $portal); $checked++; + $consecutiveFailures = 0; $this->line(sprintf(' [%d] %s → %s', $release->id, $release->slug, $check->state->label())); - } catch (\Throwable $e) { + } catch (SearchConsoleUnavailable $e) { + // Google hängt oder hat einen eigenen Fehler: kein + // Anwendungsfehler, nur Warnung — die Meldung bleibt fällig. $failed++; + $consecutiveFailures++; + Log::warning('Search Console vorübergehend nicht verfügbar.', ['release_id' => $release->id, 'error' => $e->getMessage()]); + $this->warn(sprintf(' [%d] %s: %s', $release->id, $release->slug, $e->getMessage())); + } catch (\Throwable $e) { + // Auth, Rechte, Quote, kaputte Antwort: echter Fehler. + $failed++; + $consecutiveFailures++; report($e); $this->error(sprintf(' [%d] %s: %s', $release->id, $release->slug, $e->getMessage())); + } - // Quote oder Auth kaputt: der Rest des Laufs würde nur - // weitere Fehler produzieren. - if ($failed >= 5 && $checked === 0) { - $this->error('Abbruch nach fünf Fehlern ohne Treffer.'); + if ($consecutiveFailures >= $maxConsecutive) { + $aborted = true; + $this->error(sprintf('%s: Abbruch nach %d Fehlern in Folge.', $portal->label(), $consecutiveFailures)); - return self::FAILURE; - } + continue 2; } } } - if (! $dryRun) { - $this->info(sprintf('%d Prüfungen gespeichert, %d fehlgeschlagen.', $checked, $failed)); + if ($dryRun) { + return self::SUCCESS; + } + + $attempted = $checked + $failed; + $this->info(sprintf('%d Prüfungen gespeichert, %d fehlgeschlagen.', $checked, $failed)); + + // Fehlschlag (→ einmaliger Betriebs-Alert über den Scheduler) nur, + // wenn ein Portal abbrechen musste oder mindestens die Hälfte + // scheiterte; einzelne Aussetzer erledigen sich am nächsten Tag. + if ($aborted || ($attempted >= 10 && $failed / $attempted >= 0.5)) { + return self::FAILURE; } return self::SUCCESS; diff --git a/app/Services/SearchConsole/SearchConsoleClient.php b/app/Services/SearchConsole/SearchConsoleClient.php index 381f277f..082c4379 100644 --- a/app/Services/SearchConsole/SearchConsoleClient.php +++ b/app/Services/SearchConsole/SearchConsoleClient.php @@ -2,9 +2,13 @@ namespace App\Services\SearchConsole; +use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\RequestException; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use RuntimeException; +use Throwable; /** * Schmaler Client für die URL Inspection API der Google Search Console @@ -59,22 +63,37 @@ class SearchConsoleClient */ public function inspect(string $url, string $siteUrl, string $languageCode = 'de-DE'): array { - $response = Http::withToken($this->accessToken()) - ->acceptJson() - ->timeout(20) - ->post(self::INSPECT_URL, [ - 'inspectionUrl' => $url, - 'siteUrl' => $siteUrl, - 'languageCode' => $languageCode, - ]); + try { + $response = Http::withToken($this->accessToken()) + ->acceptJson() + ->timeout((int) config('search_console.timeout_seconds', 20)) + ->retry( + 1 + (int) config('search_console.retries', 1), + (int) config('search_console.retry_sleep_ms', 3000), + fn (Throwable $e): bool => $e instanceof ConnectionException + || ($e instanceof RequestException && self::isTransient($e->response)), + throw: false, + ) + ->post(self::INSPECT_URL, [ + 'inspectionUrl' => $url, + 'siteUrl' => $siteUrl, + 'languageCode' => $languageCode, + ]); + } catch (ConnectionException $e) { + // Ohne Millisekundenangabe im Text: GlitchTip gruppierte die + // Zeitüberschreitungen sonst nach 20000/20001/20002 ms getrennt. + throw new SearchConsoleUnavailable('URL Inspection API nicht erreichbar (Zeitüberschreitung oder Verbindungsfehler) für '.$url, previous: $e); + } if (! $response->successful()) { - throw new RuntimeException(sprintf( + $message = sprintf( 'URL Inspection API antwortet mit %d für %s: %s', $response->status(), $url, (string) ($response->json('error.message') ?? $response->body()), - )); + ); + + throw self::isTransient($response) ? new SearchConsoleUnavailable($message) : new RuntimeException($message); } $result = $response->json('inspectionResult'); @@ -82,6 +101,16 @@ class SearchConsoleClient return is_array($result) ? $result : []; } + /** + * Vorübergehend: Google-seitiger Fehler (5xx) oder Drosselung (429). + * Alles andere (401/403/400) ist Konfiguration oder Anfrage und wird + * nicht wiederholt. + */ + private static function isTransient(Response $response): bool + { + return $response->serverError() || $response->status() === 429; + } + /** * Zugriffstoken des Service-Kontos, 50 Minuten gecacht (Gültigkeit 60). */ diff --git a/app/Services/SearchConsole/SearchConsoleUnavailable.php b/app/Services/SearchConsole/SearchConsoleUnavailable.php new file mode 100644 index 00000000..9c0f149b --- /dev/null +++ b/app/Services/SearchConsole/SearchConsoleUnavailable.php @@ -0,0 +1,14 @@ + (int) env('GSC_DAILY_BUDGET', 300), + // Robustheit des Laufs (06.10.2026: Google hing zeitweise 20 s je Abfrage, + // der Lauf zog sich über 1¾ Stunden). Vorübergehende Fehler (Timeout, + // 5xx, 429) werden einmal wiederholt; nach mehreren Fehlern in Folge + // bricht das Portal ab; der Lauf endet spätestens nach dem Zeitbudget + // (unter der Overlap-Sperre von 60 Minuten im Scheduler). Nicht + // geprüfte Meldungen bleiben fällig und kommen am nächsten Tag dran. + 'timeout_seconds' => 20, + 'retries' => 1, + 'retry_sleep_ms' => 3000, + 'max_consecutive_failures' => 5, + 'max_runtime_minutes' => 40, + // Wie oft eine Meldung erneut geprüft wird, gestaffelt nach Alter seit // Veröffentlichung: [maximales Alter in Tagen => Abstand in Tagen]. // Die letzte Stufe gilt für alles Ältere. diff --git a/tests/Feature/IndexStatusCheckTest.php b/tests/Feature/IndexStatusCheckTest.php index 7924ae17..1752dde3 100644 --- a/tests/Feature/IndexStatusCheckTest.php +++ b/tests/Feature/IndexStatusCheckTest.php @@ -13,7 +13,9 @@ use App\Services\SearchConsole\IndexStatusChecker; use App\Services\SearchConsole\IndexStatusReport; use App\Services\SearchConsole\SearchConsoleClient; use Database\Seeders\RolesAndPermissionsSeeder; +use Illuminate\Support\Facades\Exceptions; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Tests\TestCase; /** @@ -151,6 +153,124 @@ test('the command does nothing without configuration', function () { expect(PressReleaseIndexCheck::query()->count())->toBe(0); }); +/** + * Robustheit des Laufs (06.10.2026: 28 Zeitüberschreitungen und 4 × 500 von + * Google, jede einzeln als Fehler gemeldet, Lauf über 1¾ Stunden). + */ +function inspectionOk(): array +{ + return ['inspectionResult' => ['indexStatusResult' => [ + 'verdict' => 'PASS', + 'coverageState' => 'Submitted and indexed', + 'indexingState' => 'INDEXING_ALLOWED', + ]]]; +} + +function robustIndexRun(): void +{ + fakeServiceAccount(); + config()->set('search_console.properties.presseecho', 'sc-domain:presseecho.test'); + config()->set('search_console.properties.businessportal24', null); + config()->set('search_console.retry_sleep_ms', 0); +} + +test('a google timeout is retried once and then counts as a normal check', function () { + /** @var TestCase $this */ + robustIndexRun(); + publishedOn(Portal::Presseecho, 2); + + Http::fake([ + 'https://oauth2.googleapis.com/token' => Http::response(['access_token' => 'token-123']), + SearchConsoleClient::INSPECT_URL => Http::sequence() + ->pushFailedConnection('cURL error 28: Operation timed out after 20002 milliseconds') + ->push(inspectionOk()), + ]); + + $this->artisan(CheckIndexStatus::class, ['--budget' => 5]) + ->expectsOutputToContain('1 Prüfungen gespeichert, 0 fehlgeschlagen.') + ->assertSuccessful(); + + Http::assertSentCount(3); + expect(PressReleaseIndexCheck::query()->count())->toBe(1); +}); + +test('temporary google failures are logged as warnings, not reported, and stay due', function () { + /** @var TestCase $this */ + robustIndexRun(); + Exceptions::fake(); + Log::spy(); + publishedOn(Portal::Presseecho, 2); + publishedOn(Portal::Presseecho, 3); + + Http::fake([ + 'https://oauth2.googleapis.com/token' => Http::response(['access_token' => 'token-123']), + SearchConsoleClient::INSPECT_URL => Http::response(['error' => ['message' => 'Internal error encountered.']], 500), + ]); + + $this->artisan(CheckIndexStatus::class, ['--budget' => 5]) + ->expectsOutputToContain('0 Prüfungen gespeichert, 2 fehlgeschlagen.') + ->assertSuccessful(); + + Exceptions::assertNothingReported(); + Log::shouldHaveReceived('warning')->with('Search Console vorübergehend nicht verfügbar.', Mockery::any())->twice(); + expect(PressReleaseIndexCheck::query()->count())->toBe(0) + ->and(app(IndexStatusChecker::class)->candidates(Portal::Presseecho, 5))->toHaveCount(2); +}); + +test('a portal is aborted after five failures in a row and the run fails once', function () { + /** @var TestCase $this */ + robustIndexRun(); + Exceptions::fake(); + foreach (range(1, 8) as $day) { + publishedOn(Portal::Presseecho, $day); + } + + Http::fake([ + 'https://oauth2.googleapis.com/token' => Http::response(['access_token' => 'token-123']), + SearchConsoleClient::INSPECT_URL => Http::failedConnection(), + ]); + + $this->artisan(CheckIndexStatus::class, ['--budget' => 10]) + ->expectsOutputToContain('Abbruch nach 5 Fehlern in Folge') + ->assertFailed(); + + // Fünf Meldungen mit je einer Wiederholung plus Token — nicht alle acht. + Http::assertSentCount(1 + 5 * 2); + Exceptions::assertNothingReported(); +}); + +test('a configuration error is reported and not retried', function () { + /** @var TestCase $this */ + robustIndexRun(); + Exceptions::fake(); + publishedOn(Portal::Presseecho, 2); + + Http::fake([ + 'https://oauth2.googleapis.com/token' => Http::response(['access_token' => 'token-123']), + SearchConsoleClient::INSPECT_URL => Http::response(['error' => ['message' => 'User does not have sufficient permission for site.']], 403), + ]); + + $this->artisan(CheckIndexStatus::class, ['--budget' => 5])->assertSuccessful(); + + Http::assertSentCount(2); + Exceptions::assertReported(fn (RuntimeException $e) => str_contains($e->getMessage(), 'antwortet mit 403')); +}); + +test('the run stops at its time budget and leaves the rest for tomorrow', function () { + /** @var TestCase $this */ + robustIndexRun(); + config()->set('search_console.max_runtime_minutes', 0); + publishedOn(Portal::Presseecho, 2); + + Http::fake(); + + $this->artisan(CheckIndexStatus::class, ['--budget' => 5]) + ->expectsOutputToContain('Zeitbudget erreicht') + ->assertSuccessful(); + + Http::assertNothingSent(); +}); + test('the admin report splits the distribution by origin and lists the latest checks', function () { /** @var TestCase $this */ test()->seed(RolesAndPermissionsSeeder::class);