diff --git a/src/server/ctydat.js b/src/server/ctydat.js index 3106a05d..8695d68a 100644 --- a/src/server/ctydat.js +++ b/src/server/ctydat.js @@ -19,6 +19,11 @@ try { const CTY_URL = 'https://www.country-files.com/bigcty/cty.dat'; const REFRESH_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours +// Retry backoff for transient fetch failures (slow DNS, momentary server lag, +// container boot race with the network). Without this a single failed startup +// fetch strands the app on the built-in fallback prefix table for 24h. +const CTY_RETRY_ATTEMPTS = 3; +const CTY_RETRY_BASE_DELAY_MS = 5000; // 5s, 10s, 20s let ctyCache = null; // { prefixes: {}, exact: {}, entities: [], timestamp } let fetchTimer = null; @@ -179,14 +184,16 @@ function parseCtyDat(text) { } /** - * Fetch and parse cty.dat from country-files.com + * Fetch and parse cty.dat from country-files.com. + * @param {function} [fetchImpl] - injectable fetch for testing */ -async function fetchAndParse() { +async function fetchAndParse(fetchImpl) { + const doFetch = fetchImpl || fetch; try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15000); - const response = await fetch(CTY_URL, { + const response = await doFetch(CTY_URL, { signal: controller.signal, headers: { 'User-Agent': 'OpenHamClock' }, }); @@ -211,15 +218,40 @@ async function fetchAndParse() { } } +/** + * Retry fetchAndParse with exponential backoff so a transient failure + * (timeout, slow DNS, container boot race) self-heals within seconds + * instead of waiting 24h for the next scheduled refresh. + * @param {object} [opts] + * @param {number} [opts.maxAttempts=CTY_RETRY_ATTEMPTS] + * @param {number} [opts.baseDelayMs=CTY_RETRY_BASE_DELAY_MS] + * @param {function} [opts.fetchImpl] - injectable fetch for testing + * @returns {Promise} true if the fetch succeeded + */ +async function fetchWithRetry(opts = {}) { + const maxAttempts = opts.maxAttempts ?? CTY_RETRY_ATTEMPTS; + const baseDelayMs = opts.baseDelayMs ?? CTY_RETRY_BASE_DELAY_MS; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const ok = await fetchAndParse(opts.fetchImpl); + if (ok) return true; + if (attempt < maxAttempts) { + const delay = baseDelayMs * Math.pow(2, attempt - 1); + console.warn(`[CTY] Retrying in ${delay / 1000}s (attempt ${attempt}/${maxAttempts})`); + await new Promise((r) => setTimeout(r, delay)); + } + } + return false; +} + /** * Initialize: fetch on startup and schedule periodic refresh */ async function initCtyData() { - await fetchAndParse(); + await fetchWithRetry(); // Refresh every 24 hours if (fetchTimer) clearInterval(fetchTimer); - fetchTimer = setInterval(fetchAndParse, REFRESH_INTERVAL); + fetchTimer = setInterval(() => fetchWithRetry(), REFRESH_INTERVAL); } /** @@ -276,4 +308,4 @@ function lookupCall(call) { return null; } -module.exports = { initCtyData, getCtyData, lookupCall, parseCtyDat }; +module.exports = { initCtyData, getCtyData, lookupCall, parseCtyDat, fetchWithRetry }; diff --git a/src/server/ctydat.test.js b/src/server/ctydat.test.js new file mode 100644 index 00000000..ae1b35d5 --- /dev/null +++ b/src/server/ctydat.test.js @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fetchWithRetry, parseCtyDat } from './ctydat.js'; + +// Minimal valid cty.dat fragment matching the AD1C format: +// header line at column 0, alias prefixes on indented lines, block ends with ';' +const CTY_SAMPLE = [ + 'United States: 05: 08: NA: 43.00: 100.00: -5.0: K: ', + ' K,KA,KB,KC,KD,KE,N,W,AA,AB,AC,KH6,NH6,=K1AAA;', +].join('\n'); + +function makeOkResponse(text) { + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(text), + }); +} + +function makeFailResponse() { + return Promise.reject(new Error('request failed, reason: ')); +} + +describe('fetchWithRetry', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('succeeds on the first attempt without retrying', async () => { + const fetchImpl = vi.fn(() => makeOkResponse(CTY_SAMPLE)); + const ok = await fetchWithRetry({ + maxAttempts: 3, + baseDelayMs: 1, + fetchImpl, + }); + expect(ok).toBe(true); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('retries with backoff and succeeds on a later attempt', async () => { + const fetchImpl = vi + .fn() + .mockImplementationOnce(makeFailResponse) + .mockImplementationOnce(makeFailResponse) + .mockImplementationOnce(() => makeOkResponse(CTY_SAMPLE)); + + const ok = await fetchWithRetry({ + maxAttempts: 3, + baseDelayMs: 1, + fetchImpl, + }); + + expect(ok).toBe(true); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + + it('gives up after maxAttempts and returns false', async () => { + const fetchImpl = vi.fn(makeFailResponse); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const ok = await fetchWithRetry({ + maxAttempts: 3, + baseDelayMs: 1, + fetchImpl, + }); + + expect(ok).toBe(false); + expect(fetchImpl).toHaveBeenCalledTimes(3); + // Should log a retry warning between attempts (not after the last one) + const retryWarnings = warnSpy.mock.calls.filter((c) => String(c[0]).includes('Retrying')); + expect(retryWarnings).toHaveLength(2); + }); + + it('parses the fetched cty.dat into entities and prefixes', async () => { + const fetchImpl = vi.fn(() => makeOkResponse(CTY_SAMPLE)); + await fetchWithRetry({ + maxAttempts: 1, + baseDelayMs: 1, + fetchImpl, + }); + // parseCtyDat is exported — verify the sample round-trips + const parsed = parseCtyDat(CTY_SAMPLE); + expect(parsed.entities.length).toBe(1); + expect(parsed.entities[0].entity).toBe('United States'); + expect(parsed.entities[0].dxcc).toBe('K'); + expect(Object.keys(parsed.prefixes).length).toBeGreaterThan(0); + // Exact callsign match (prefixed with =) + expect(parsed.exact['K1AAA']).toBeDefined(); + expect(parsed.exact['K1AAA'].entity).toBe('United States'); + }); +});