fix(sms,apps): login code no longer waits ~15s; wage quotes stop re-requesting
SMS: with SMS_RELAY_HOST unset inside Docker, every send first looked up the server's public IP from ipify / ifconfig.me / icanhazip, one after another with a 5s timeout each. They are unreachable from the server, and a failed lookup was not remembered, so EVERY SMS, the login code included, waited ~15s. The three are now tried in parallel (3s), concurrent sends share one lookup, and a failure is not retried for 10 minutes. Apps: the server answers /customer/me/wage-quote only for variants that exist. The store re-asked for every unanswered id from a computed, and each answer replaced `quotes`, re-running every card, so one missing variant kept the app posting wage-quote in a loop. Unanswered ids are now remembered, an empty answer does not touch `quotes`, and a failed call waits 30s. Tests: relay-host.spec (failed lookup not repeated, one shared lookup). اپ: کد ورود بدون معطلی ارسال میشود. اپ: صفحهها بعد از ورود دیگر چند بار پشت سر هم بارگذاری نمیشوند. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -35,8 +35,9 @@ describe('relay host', () => {
|
|||||||
fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => '81.12.34.56\n' });
|
fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => '81.12.34.56\n' });
|
||||||
expect(await resolveRelayHost()).toBe('81.12.34.56');
|
expect(await resolveRelayHost()).toBe('81.12.34.56');
|
||||||
expect(relayConfig().host).toBe('81.12.34.56');
|
expect(relayConfig().host).toBe('81.12.34.56');
|
||||||
|
const calls = fetchMock.mock.calls.length;
|
||||||
await resolveRelayHost();
|
await resolveRelayHost();
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(calls);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('SMS_RELAY_HOST always wins, with no lookup', async () => {
|
it('SMS_RELAY_HOST always wins, with no lookup', async () => {
|
||||||
@@ -57,4 +58,20 @@ describe('relay host', () => {
|
|||||||
fetchMock.mockRejectedValue(new Error('offline'));
|
fetchMock.mockRejectedValue(new Error('offline'));
|
||||||
expect(await resolveRelayHost()).toBe('172.18.0.5');
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,29 +52,45 @@ export function isPrivateIpv4(ip: string | null): boolean {
|
|||||||
*/
|
*/
|
||||||
let publicIpv4: string | null = null;
|
let publicIpv4: string | null = null;
|
||||||
const PUBLIC_IP_SERVICES = ['https://api.ipify.org', 'https://ifconfig.me/ip', 'https://icanhazip.com'];
|
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<void> | null = null;
|
||||||
|
let lookupFailedAt = 0;
|
||||||
|
|
||||||
|
async function lookupPublicIpv4(): Promise<void> {
|
||||||
|
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<string | null> {
|
export async function resolveRelayHost(): Promise<string | null> {
|
||||||
if (envOrNull('SMS_RELAY_HOST') || publicIpv4) return relayConfig().host;
|
if (envOrNull('SMS_RELAY_HOST') || publicIpv4) return relayConfig().host;
|
||||||
const own = serverIpv4();
|
const own = serverIpv4();
|
||||||
if (!isPrivateIpv4(own)) return own;
|
if (!isPrivateIpv4(own)) return own;
|
||||||
for (const url of PUBLIC_IP_SERVICES) {
|
if (Date.now() - lookupFailedAt < FAILED_LOOKUP_RETRY_MS) return relayConfig().host;
|
||||||
try {
|
lookup ??= lookupPublicIpv4().finally(() => {
|
||||||
const reply = await postWithTimeout(url, {}, null, 5_000, 'GET');
|
lookup = null;
|
||||||
const ip = reply.body.trim();
|
});
|
||||||
if (reply.ok && /^\d{1,3}(\.\d{1,3}){3}$/.test(ip) && !isPrivateIpv4(ip)) {
|
await lookup;
|
||||||
publicIpv4 = ip;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* try the next service */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return relayConfig().host;
|
return relayConfig().host;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tests only. */
|
/** Tests only. */
|
||||||
export function resetRelayHostCache() {
|
export function resetRelayHostCache() {
|
||||||
publicIpv4 = null;
|
publicIpv4 = null;
|
||||||
|
lookup = null;
|
||||||
|
lookupFailedAt = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function relayConfig() {
|
export function relayConfig() {
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ export const useWageQuoteStore = defineStore('wageQuotes', () => {
|
|||||||
const quotes = ref<Record<string, WageQuote>>({})
|
const quotes = ref<Record<string, WageQuote>>({})
|
||||||
const pending = new Set<string>()
|
const pending = new Set<string>()
|
||||||
const inFlight = new Set<string>()
|
const inFlight = new Set<string>()
|
||||||
|
// 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<string>()
|
||||||
|
// A failed call is retried, but not before this time.
|
||||||
|
let retryAfter = 0
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
// A different customer has a different percent: never reuse another's quotes.
|
// A different customer has a different percent: never reuse another's quotes.
|
||||||
@@ -23,6 +28,8 @@ export const useWageQuoteStore = defineStore('wageQuotes', () => {
|
|||||||
quotes.value = {}
|
quotes.value = {}
|
||||||
pending.clear()
|
pending.clear()
|
||||||
inFlight.clear()
|
inFlight.clear()
|
||||||
|
unanswered.clear()
|
||||||
|
retryAfter = 0
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,10 +44,17 @@ export const useWageQuoteStore = defineStore('wageQuotes', () => {
|
|||||||
if (!ids.length) return
|
if (!ids.length) return
|
||||||
try {
|
try {
|
||||||
const rows = await api.getWageQuotes(ids)
|
const rows = await api.getWageQuotes(ids)
|
||||||
|
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 }
|
const next = { ...quotes.value }
|
||||||
for (const r of rows) next[r.variantId] = r
|
for (const r of rows) next[r.variantId] = r
|
||||||
quotes.value = next
|
quotes.value = next
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
retryAfter = Date.now() + 30_000
|
||||||
console.warn('wage quote failed', e)
|
console.warn('wage quote failed', e)
|
||||||
} finally {
|
} finally {
|
||||||
ids.forEach((id) => inFlight.delete(id))
|
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). */
|
/** Asks for a variant's quote (idempotent; safe to call from a computed). */
|
||||||
function request(variantId: string) {
|
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)
|
pending.add(variantId)
|
||||||
if (!timer) timer = setTimeout(flush, 30)
|
if (!timer) timer = setTimeout(flush, 30)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ export const useWageQuoteStore = defineStore('wageQuotes', () => {
|
|||||||
const quotes = ref<Record<string, WageQuote>>({})
|
const quotes = ref<Record<string, WageQuote>>({})
|
||||||
const pending = new Set<string>()
|
const pending = new Set<string>()
|
||||||
const inFlight = new Set<string>()
|
const inFlight = new Set<string>()
|
||||||
|
// 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<string>()
|
||||||
|
// A failed call is retried, but not before this time.
|
||||||
|
let retryAfter = 0
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
// A different customer has a different percent: never reuse another's quotes.
|
// A different customer has a different percent: never reuse another's quotes.
|
||||||
@@ -23,6 +28,8 @@ export const useWageQuoteStore = defineStore('wageQuotes', () => {
|
|||||||
quotes.value = {}
|
quotes.value = {}
|
||||||
pending.clear()
|
pending.clear()
|
||||||
inFlight.clear()
|
inFlight.clear()
|
||||||
|
unanswered.clear()
|
||||||
|
retryAfter = 0
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,10 +44,17 @@ export const useWageQuoteStore = defineStore('wageQuotes', () => {
|
|||||||
if (!ids.length) return
|
if (!ids.length) return
|
||||||
try {
|
try {
|
||||||
const rows = await api.getWageQuotes(ids)
|
const rows = await api.getWageQuotes(ids)
|
||||||
|
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 }
|
const next = { ...quotes.value }
|
||||||
for (const r of rows) next[r.variantId] = r
|
for (const r of rows) next[r.variantId] = r
|
||||||
quotes.value = next
|
quotes.value = next
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
retryAfter = Date.now() + 30_000
|
||||||
console.warn('wage quote failed', e)
|
console.warn('wage quote failed', e)
|
||||||
} finally {
|
} finally {
|
||||||
ids.forEach((id) => inFlight.delete(id))
|
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). */
|
/** Asks for a variant's quote (idempotent; safe to call from a computed). */
|
||||||
function request(variantId: string) {
|
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)
|
pending.add(variantId)
|
||||||
if (!timer) timer = setTimeout(flush, 30)
|
if (!timer) timer = setTimeout(flush, 30)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user