search-console: vorübergehende Google-Fehler einmal wiederholt und nur als Warnung protokolliert, Portal-Abbruch nach fünf Fehlern in Folge, Zeitbudget 40 Minuten (GlitchTip server-pressekonto 11–14)

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 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-06 13:12:29 +00:00
parent 17dceec4f7
commit df557e419b
5 changed files with 228 additions and 19 deletions

View file

@ -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;

View file

@ -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).
*/

View file

@ -0,0 +1,14 @@
<?php
namespace App\Services\SearchConsole;
use RuntimeException;
/**
* Google antwortet vorübergehend nicht (Zeitüberschreitung, Verbindungsfehler,
* 5xx, 429) — auch nach der Wiederholung. Kein Fehler der Anwendung: die
* Meldung bleibt fällig und wird beim nächsten Lauf erneut geprüft. Der
* Befehl protokolliert das als Warnung statt es zu melden (GlitchTip
* server-pressekonto, Issues 11–14, 06.10.2026).
*/
class SearchConsoleUnavailable extends RuntimeException {}