diff --git a/apps/api/src/modules/sms/drivers/relay-host.spec.ts b/apps/api/src/modules/sms/drivers/relay-host.spec.ts index 215e8cd..4baa380 100644 --- a/apps/api/src/modules/sms/drivers/relay-host.spec.ts +++ b/apps/api/src/modules/sms/drivers/relay-host.spec.ts @@ -35,8 +35,9 @@ describe('relay host', () => { fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => '81.12.34.56\n' }); expect(await resolveRelayHost()).toBe('81.12.34.56'); expect(relayConfig().host).toBe('81.12.34.56'); + const calls = fetchMock.mock.calls.length; await resolveRelayHost(); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(calls); }); it('SMS_RELAY_HOST always wins, with no lookup', async () => { @@ -57,4 +58,20 @@ describe('relay host', () => { fetchMock.mockRejectedValue(new Error('offline')); expect(await resolveRelayHost()).toBe('172.18.0.5'); }); + + it('a failed lookup is not repeated on the next SMS, so sending does not wait on it again', async () => { + networkInterfaces.mockReturnValue({ eth0: [iface('172.18.0.5')] }); + fetchMock.mockRejectedValue(new Error('offline')); + await resolveRelayHost(); + const calls = fetchMock.mock.calls.length; + expect(await resolveRelayHost()).toBe('172.18.0.5'); + expect(fetchMock).toHaveBeenCalledTimes(calls); + }); + + it('concurrent sends share one lookup', async () => { + networkInterfaces.mockReturnValue({ eth0: [iface('172.18.0.5')] }); + fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => '81.12.34.56' }); + await Promise.all([resolveRelayHost(), resolveRelayHost(), resolveRelayHost()]); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); }); diff --git a/apps/api/src/modules/sms/drivers/relay.driver.ts b/apps/api/src/modules/sms/drivers/relay.driver.ts index e7532a4..24715f7 100644 --- a/apps/api/src/modules/sms/drivers/relay.driver.ts +++ b/apps/api/src/modules/sms/drivers/relay.driver.ts @@ -52,29 +52,45 @@ export function isPrivateIpv4(ip: string | null): boolean { */ let publicIpv4: string | null = null; const PUBLIC_IP_SERVICES = ['https://api.ipify.org', 'https://ifconfig.me/ip', 'https://icanhazip.com']; +const LOOKUP_TIMEOUT_MS = 3_000; +// A failed lookup is not repeated before this: the services are often +// unreachable from the server, and every SMS (the login code too) waited +// 3 × 5s on them before it was sent. +const FAILED_LOOKUP_RETRY_MS = 10 * 60 * 1000; +let lookup: Promise | null = null; +let lookupFailedAt = 0; + +async function lookupPublicIpv4(): Promise { + const attempts = PUBLIC_IP_SERVICES.map(async (url) => { + const reply = await postWithTimeout(url, {}, null, LOOKUP_TIMEOUT_MS, 'GET'); + const ip = reply.body.trim(); + if (reply.ok && /^\d{1,3}(\.\d{1,3}){3}$/.test(ip) && !isPrivateIpv4(ip)) return ip; + throw new Error('not a public IPv4'); + }); + try { + publicIpv4 = await Promise.any(attempts); + } catch { + lookupFailedAt = Date.now(); + } +} export async function resolveRelayHost(): Promise { if (envOrNull('SMS_RELAY_HOST') || publicIpv4) return relayConfig().host; const own = serverIpv4(); if (!isPrivateIpv4(own)) return own; - for (const url of PUBLIC_IP_SERVICES) { - try { - const reply = await postWithTimeout(url, {}, null, 5_000, 'GET'); - const ip = reply.body.trim(); - if (reply.ok && /^\d{1,3}(\.\d{1,3}){3}$/.test(ip) && !isPrivateIpv4(ip)) { - publicIpv4 = ip; - break; - } - } catch { - /* try the next service */ - } - } + if (Date.now() - lookupFailedAt < FAILED_LOOKUP_RETRY_MS) return relayConfig().host; + lookup ??= lookupPublicIpv4().finally(() => { + lookup = null; + }); + await lookup; return relayConfig().host; } /** Tests only. */ export function resetRelayHostCache() { publicIpv4 = null; + lookup = null; + lookupFailedAt = 0; } export function relayConfig() { diff --git a/apps/user-app-v2/src/stores/wageQuoteStore.ts b/apps/user-app-v2/src/stores/wageQuoteStore.ts index e09723c..a501d37 100644 --- a/apps/user-app-v2/src/stores/wageQuoteStore.ts +++ b/apps/user-app-v2/src/stores/wageQuoteStore.ts @@ -14,6 +14,11 @@ export const useWageQuoteStore = defineStore('wageQuotes', () => { const quotes = ref>({}) const pending = new Set() const inFlight = new Set() + // Asked for but not answered: the server only returns variants that exist, + // and re-asking from a computed on every re-render looped forever. + const unanswered = new Set() + // A failed call is retried, but not before this time. + let retryAfter = 0 let timer: ReturnType | null = null // A different customer has a different percent: never reuse another's quotes. @@ -23,6 +28,8 @@ export const useWageQuoteStore = defineStore('wageQuotes', () => { quotes.value = {} pending.clear() inFlight.clear() + unanswered.clear() + retryAfter = 0 }, ) @@ -37,10 +44,17 @@ export const useWageQuoteStore = defineStore('wageQuotes', () => { if (!ids.length) return try { const rows = await api.getWageQuotes(ids) - const next = { ...quotes.value } - for (const r of rows) next[r.variantId] = r - quotes.value = next + const answered = new Set(rows.map((r) => r.variantId)) + ids.forEach((id) => { + if (!answered.has(id)) unanswered.add(id) + }) + if (rows.length) { + const next = { ...quotes.value } + for (const r of rows) next[r.variantId] = r + quotes.value = next + } } catch (e) { + retryAfter = Date.now() + 30_000 console.warn('wage quote failed', e) } finally { ids.forEach((id) => inFlight.delete(id)) @@ -49,7 +63,8 @@ export const useWageQuoteStore = defineStore('wageQuotes', () => { /** Asks for a variant's quote (idempotent; safe to call from a computed). */ function request(variantId: string) { - if (!session.token || quotes.value[variantId] || pending.has(variantId) || inFlight.has(variantId)) return + if (!session.token || Date.now() < retryAfter) return + if (quotes.value[variantId] || unanswered.has(variantId) || pending.has(variantId) || inFlight.has(variantId)) return pending.add(variantId) if (!timer) timer = setTimeout(flush, 30) } diff --git a/apps/user-app/src/stores/wageQuoteStore.ts b/apps/user-app/src/stores/wageQuoteStore.ts index e09723c..a501d37 100644 --- a/apps/user-app/src/stores/wageQuoteStore.ts +++ b/apps/user-app/src/stores/wageQuoteStore.ts @@ -14,6 +14,11 @@ export const useWageQuoteStore = defineStore('wageQuotes', () => { const quotes = ref>({}) const pending = new Set() const inFlight = new Set() + // Asked for but not answered: the server only returns variants that exist, + // and re-asking from a computed on every re-render looped forever. + const unanswered = new Set() + // A failed call is retried, but not before this time. + let retryAfter = 0 let timer: ReturnType | null = null // A different customer has a different percent: never reuse another's quotes. @@ -23,6 +28,8 @@ export const useWageQuoteStore = defineStore('wageQuotes', () => { quotes.value = {} pending.clear() inFlight.clear() + unanswered.clear() + retryAfter = 0 }, ) @@ -37,10 +44,17 @@ export const useWageQuoteStore = defineStore('wageQuotes', () => { if (!ids.length) return try { const rows = await api.getWageQuotes(ids) - const next = { ...quotes.value } - for (const r of rows) next[r.variantId] = r - quotes.value = next + const answered = new Set(rows.map((r) => r.variantId)) + ids.forEach((id) => { + if (!answered.has(id)) unanswered.add(id) + }) + if (rows.length) { + const next = { ...quotes.value } + for (const r of rows) next[r.variantId] = r + quotes.value = next + } } catch (e) { + retryAfter = Date.now() + 30_000 console.warn('wage quote failed', e) } finally { ids.forEach((id) => inFlight.delete(id)) @@ -49,7 +63,8 @@ export const useWageQuoteStore = defineStore('wageQuotes', () => { /** Asks for a variant's quote (idempotent; safe to call from a computed). */ function request(variantId: string) { - if (!session.token || quotes.value[variantId] || pending.has(variantId) || inFlight.has(variantId)) return + if (!session.token || Date.now() < retryAfter) return + if (quotes.value[variantId] || unanswered.has(variantId) || pending.has(variantId) || inFlight.has(variantId)) return pending.add(variantId) if (!timer) timer = setTimeout(flush, 30) }