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
This commit is contained in:
ThatGuySam 2026-04-04 18:18:23 -05:00
parent cd41143f0d
commit f1cb66c477
3 changed files with 159 additions and 21 deletions

View file

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

View file

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

View file

@ -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 )
} )
} )