From f1cb66c477594282d9fc7c9f55754dd270a29922 Mon Sep 17 00:00:00 2001 From: ThatGuySam Date: Sat, 4 Apr 2026 18:18:23 -0500 Subject: [PATCH] fix(build): retry transient sitemap endpoint fetches The TypeScript helper refactor exposed a separate production risk: builds can fail when the sitemap-endpoints API returns a transient 5xx during Pagefind index generation. Move that fetch into a small helper with retry logic and add a focused prebuild test so this failure mode is caught without waiting on a full deploy. Constraint: Must keep Pagefind index generation behavior the same when the endpoint is healthy Rejected: Ignore the failure as external-only | transient 5xx responses can still block deploys and CI repeatedly Confidence: high Scope-risk: narrow Reversibility: clean Directive: When external build inputs can fail transiently, add a small retryable helper plus a direct test before relying on full deploy verification Tested: pnpm exec vitest run test/prebuild/load-sitemap-endpoints.test.ts; pnpm run typecheck; pnpm run test-prebuild Not-tested: Full production redeploy completion before push --- helpers/pagefind/load-sitemap-endpoints.ts | 63 +++++++++++++ scripts/build-pagefind-index.js | 23 +---- test/prebuild/load-sitemap-endpoints.test.ts | 94 ++++++++++++++++++++ 3 files changed, 159 insertions(+), 21 deletions(-) create mode 100644 helpers/pagefind/load-sitemap-endpoints.ts create mode 100644 test/prebuild/load-sitemap-endpoints.test.ts diff --git a/helpers/pagefind/load-sitemap-endpoints.ts b/helpers/pagefind/load-sitemap-endpoints.ts new file mode 100644 index 0000000..dd27fe4 --- /dev/null +++ b/helpers/pagefind/load-sitemap-endpoints.ts @@ -0,0 +1,63 @@ +import fs from 'fs-extra' +import axios from 'axios' + +import { + sitemapEndpointsPath +} from '~/helpers/pagefind/config.js' + +function shouldRetryError ( error: unknown ) { + const status = ( error as { response?: { status?: number } } )?.response?.status + + return typeof status === 'number' && status >= 500 +} + +async function fetchJsonWithRetries ( + url: string, + { + attempts = 3, + delayMs = 1000 + }: { + attempts?: number + delayMs?: number + } = {} +) { + let lastError: unknown + + for ( let attempt = 1; attempt <= attempts; attempt += 1 ) { + try { + const response = await axios.get( url ) + + return response.data + } catch ( error ) { + lastError = error + + if ( attempt >= attempts || !shouldRetryError( error ) ) { + throw error + } + + await new Promise( resolve => setTimeout( resolve, delayMs ) ) + } + } + + throw lastError +} + +export async function loadSitemapEndpoints () { + if ( await fs.pathExists( sitemapEndpointsPath ) ) { + return await fs.readJson( sitemapEndpointsPath ) + } + + if ( !process.env.PUBLIC_API_DOMAIN ) { + throw new Error(`Missing ${ sitemapEndpointsPath } and PUBLIC_API_DOMAIN is not set`) + } + + const apiUrl = new URL( process.env.PUBLIC_API_DOMAIN ) + apiUrl.pathname = sitemapEndpointsPath.replace(/^\.?\/?static\//, '/') + + return await fetchJsonWithRetries( apiUrl.toString() ) +} + +export { + fetchJsonWithRetries, + shouldRetryError +} diff --git a/scripts/build-pagefind-index.js b/scripts/build-pagefind-index.js index a86519c..afa72fa 100644 --- a/scripts/build-pagefind-index.js +++ b/scripts/build-pagefind-index.js @@ -1,31 +1,12 @@ -import fs from 'fs-extra' -import axios from 'axios' import 'dotenv/config.js' import { - sitemapEndpointsPath -} from '~/helpers/pagefind/config.js' + loadSitemapEndpoints +} from '~/helpers/pagefind/load-sitemap-endpoints' import { writePagefindIndex } from '~/helpers/pagefind/index.js' -async function loadSitemapEndpoints () { - if ( await fs.pathExists( sitemapEndpointsPath ) ) { - return await fs.readJson( sitemapEndpointsPath ) - } - - if ( !process.env.PUBLIC_API_DOMAIN ) { - throw new Error(`Missing ${ sitemapEndpointsPath } and PUBLIC_API_DOMAIN is not set`) - } - - const apiUrl = new URL( process.env.PUBLIC_API_DOMAIN ) - apiUrl.pathname = sitemapEndpointsPath.replace(/^\.?\/?static\//, '/') - - const response = await axios.get( apiUrl.toString() ) - - return response.data -} - ;(async () => { const sitemapEndpoints = await loadSitemapEndpoints() const { diff --git a/test/prebuild/load-sitemap-endpoints.test.ts b/test/prebuild/load-sitemap-endpoints.test.ts new file mode 100644 index 0000000..48c4f4f --- /dev/null +++ b/test/prebuild/load-sitemap-endpoints.test.ts @@ -0,0 +1,94 @@ +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest' + +import axios from 'axios' + +import { + fetchJsonWithRetries, + shouldRetryError +} from '~/helpers/pagefind/load-sitemap-endpoints' + +vi.mock( 'axios', () => { + return { + default: { + get: vi.fn() + } + } +} ) + +describe( 'load sitemap endpoints helper', () => { + beforeEach( () => { + vi.mocked( axios.get ).mockReset() + } ) + + it( 'retries transient 5xx errors and eventually resolves', async () => { + const axiosGet = vi.mocked( axios.get ) + + axiosGet + .mockRejectedValueOnce({ + response: { + status: 502 + } + }) + .mockResolvedValueOnce({ + data: { + ok: true + } + } ) + + const data = await fetchJsonWithRetries( 'https://api.doesitarm.com/sitemap-endpoints.json', { + attempts: 2, + delayMs: 0 + } ) + + expect( data ).toEqual({ + ok: true + } ) + expect( axiosGet ).toHaveBeenCalledTimes( 2 ) + } ) + + it( 'does not retry non-5xx errors', async () => { + const axiosGet = vi.mocked( axios.get ) + + axiosGet.mockRejectedValueOnce({ + response: { + status: 404 + } + }) + + await expect( fetchJsonWithRetries( 'https://api.doesitarm.com/sitemap-endpoints.json', { + attempts: 3, + delayMs: 0 + } ) ).rejects.toEqual({ + response: { + status: 404 + } + }) + + expect( axiosGet ).toHaveBeenCalledTimes( 1 ) + } ) + + it( 'classifies retryable server errors', () => { + expect( shouldRetryError( { + response: { + status: 502 + } + } ) ).toBe( true ) + expect( shouldRetryError( { + response: { + status: 503 + } + } ) ).toBe( true ) + expect( shouldRetryError( { + response: { + status: 404 + } + } ) ).toBe( false ) + expect( shouldRetryError( new Error( 'network' ) ) ).toBe( false ) + } ) +} )