Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions src/server/ctydat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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' },
});
Expand All @@ -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<boolean>} 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);
}

/**
Expand Down Expand Up @@ -276,4 +308,4 @@ function lookupCall(call) {
return null;
}

module.exports = { initCtyData, getCtyData, lookupCall, parseCtyDat };
module.exports = { initCtyData, getCtyData, lookupCall, parseCtyDat, fetchWithRetry };
90 changes: 90 additions & 0 deletions src/server/ctydat.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});