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:
parent
2f02acceaf
commit
a90f24a43f
5 changed files with 228 additions and 19 deletions
|
|
@ -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.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($consecutiveFailures >= $maxConsecutive) {
|
||||
$aborted = true;
|
||||
$this->error(sprintf('%s: Abbruch nach %d Fehlern in Folge.', $portal->label(), $consecutiveFailures));
|
||||
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
try {
|
||||
$response = Http::withToken($this->accessToken())
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->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).
|
||||
*/
|
||||
|
|
|
|||
14
app/Services/SearchConsole/SearchConsoleUnavailable.php
Normal file
14
app/Services/SearchConsole/SearchConsoleUnavailable.php
Normal 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 {}
|
||||
|
|
@ -34,6 +34,18 @@ return [
|
|||
// Abfragen je Lauf und Portal.
|
||||
'daily_budget' => (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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue