From 9c7f2dc4129a12b9d75e952799a84e90bfae7522 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:13:20 +0200 Subject: [PATCH] fix(templates): keep local service templates when the CDN bundle is unusable A 200 response with an empty body or an error page replaced the local templates and the shared cache, so the service list became empty. Co-Authored-By: Claude Opus 5.5 --- app/Jobs/PullTemplatesFromCDN.php | 6 ++++- bootstrap/helpers/shared.php | 10 ++++++- .../PullServiceTemplatesFromCdnTest.php | 27 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/app/Jobs/PullTemplatesFromCDN.php b/app/Jobs/PullTemplatesFromCDN.php index e8c655449b..ec09285e1e 100644 --- a/app/Jobs/PullTemplatesFromCDN.php +++ b/app/Jobs/PullTemplatesFromCDN.php @@ -34,7 +34,11 @@ class PullTemplatesFromCDN implements ShouldBeEncrypted, ShouldQueue ->get(config('constants.services.official')); if ($response->successful()) { // Shared cache so Cloud HTTP nodes see the same bundle Horizon pulled. - store_service_templates_bundle($response->body()); + if (! store_service_templates_bundle($response->body())) { + Log::error('PullTemplatesFromCDN received an invalid bundle', [ + 'body' => str($response->body())->limit(500)->toString(), + ]); + } } else { Log::error('PullTemplatesFromCDN failed', [ 'status' => $response->status(), diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index b12a1e9a38..7b094e268d 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1484,6 +1484,12 @@ function service_templates_path(): string */ function store_service_templates_bundle(string $json, ?string $fetchedAt = null): bool { + // A 200 response can still be an empty body or an error page; keep the current templates then. + $templates = json_decode($json, true); + if (! is_array($templates) || $templates === [] || array_is_list($templates)) { + return false; + } + $fetchedAt ??= now()->toIso8601String(); $path = service_templates_path(); @@ -1530,7 +1536,9 @@ function get_service_templates(bool $force = false): Collection if ($response->failed()) { return collect([]); } - store_service_templates_bundle($response->body()); + if (! store_service_templates_bundle($response->body())) { + return get_service_templates(); + } return collect(json_decode($response->body()))->sortKeys(); } catch (Throwable) { diff --git a/tests/Feature/PullServiceTemplatesFromCdnTest.php b/tests/Feature/PullServiceTemplatesFromCdnTest.php index 848ad44b69..d70d787a76 100644 --- a/tests/Feature/PullServiceTemplatesFromCdnTest.php +++ b/tests/Feature/PullServiceTemplatesFromCdnTest.php @@ -119,6 +119,33 @@ it('skips pulling templates in local development', function () { expect(Cache::get(service_templates_cache_key()))->toBeNull(); }); +it('keeps the local templates when the CDN returns an unusable bundle', function (string $body) { + Http::fake([ + config('constants.services.official') => Http::response($body, 200), + ]); + + $path = service_templates_path(); + $original = File::get($path); + + try { + config(['app.env' => 'production']); + + (new PullTemplatesFromCDN)->handle(); + + expect(md5(File::get($path)))->toBe(md5($original)) + ->and(Cache::get(service_templates_cache_key()))->toBeNull() + ->and(get_service_templates())->not->toBeEmpty(); + } finally { + File::put($path, $original); + Cache::forget(service_templates_cache_key()); + } +})->with([ + 'empty body' => '', + 'html error page' => 'Bad gateway', + 'empty object' => '{}', + 'json list' => '[1, 2]', +]); + it('logs when the CDN responds with a non-success status', function () { Http::fake([ 'cdn.coollabs.io/*' => Http::response('nope', 503),