From 31ac6b91797404e7b1b62f3bf2da7a5a6d46aa30 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:25:51 -0400 Subject: [PATCH] Make fewer IQ Bible calls - Load the narrator list only when the audio dropdown is opened - Keep today's verse and devotional in memory for an hour (not saved) - Back off for a minute after 'allowance used up' or unreachable; keep 'nothing here' answers - Server shares one upstream call between identical simultaneous requests (nothing stored) Co-Authored-By: Claude Sonnet 5.5 --- server/iqbible.js | 25 +++++++++++--- server/iqbible.test.js | 29 +++++++++++++++- src/components/iq/IqNarratorOptions.jsx | 13 +++++-- src/pages/HomePage.jsx | 7 ++-- src/pages/SettingsPage.jsx | 7 ++-- src/utils/iqbible.js | 20 ++++++++--- src/utils/iqbible.memo.test.js | 45 +++++++++++++++++++++++++ 7 files changed, 129 insertions(+), 17 deletions(-) create mode 100644 src/utils/iqbible.memo.test.js diff --git a/server/iqbible.js b/server/iqbible.js index 525ba23..642c451 100644 --- a/server/iqbible.js +++ b/server/iqbible.js @@ -89,6 +89,24 @@ async function cacheTtl(headers, isText) { const cache = new Map(); // key -> { at, status, type, body } const CACHE_MAX = 1500; +// Requests for the same thing that arrive while one is already on its way share that one call. Nothing is kept +// afterwards (that would be caching), so this spends less of the allowance without touching the API's terms. +const inflight = new Map(); // upstream path -> Promise of { status, type, body, retryAfter } +function fetchOnce(key, headers) { + let p = inflight.get(key); + if (!p) { + p = (async () => { + const upstream = await fetch(`${BASE()}${key}`, { headers, signal: AbortSignal.timeout(20_000) }); + return { + status: upstream.status, ok: upstream.ok, retryAfter: upstream.headers.get('retry-after'), + type: upstream.headers.get('content-type') || 'application/json', body: Buffer.from(await upstream.arrayBuffer()), + }; + })().finally(() => inflight.delete(key)); + inflight.set(key, p); + } + return p; +} + const hits = new Map(); // `${userId}:${bucket}` -> { n, resetAt } function overLimit(userId, heavy) { const bucket = heavy ? 'h' : 'n'; @@ -119,12 +137,11 @@ export function registerIqBibleRoutes(app, requireAuth) { return; } try { - const upstream = await fetch(`${BASE()}${target.key}`, { headers, signal: AbortSignal.timeout(20_000) }); - const body = Buffer.from(await upstream.arrayBuffer()); - const type = upstream.headers.get('content-type') || 'application/json'; + const upstream = await fetchOnce(target.key, headers); + const { body, type } = upstream; if (upstream.status === 401) return res.status(502).json({ error: 'The IQ Bible key was rejected. Check IQBIBLE_API_KEY.' }); if (upstream.status === 429) { - const retry = upstream.headers.get('retry-after'); + const retry = upstream.retryAfter; if (retry) res.set('Retry-After', retry); return res.status(429).json({ error: 'The IQ Bible allowance is used up for now. Try again later.' }); } diff --git a/server/iqbible.test.js b/server/iqbible.test.js index 97930aa..a0ac1ec 100644 --- a/server/iqbible.test.js +++ b/server/iqbible.test.js @@ -1,5 +1,5 @@ // @vitest-environment node -import { test, expect } from 'vitest'; +import { test, expect, vi } from 'vitest'; import { resolveIqPath } from './iqbible.js'; test('only listed IQ Bible routes are reachable', () => { @@ -32,3 +32,30 @@ test('only verse and chapter text counts as cacheable text', async () => { expect(isTextPath('/commentaries/gill/JHN/3')).toBe(false); expect(resolveIqPath('/bibles/parallel', { v: 'eng_kjv,spa_rv' })).not.toBeNull(); }); + +test('identical requests made at the same moment share one upstream call, and nothing is kept afterwards', async () => { + vi.resetModules(); + process.env.IQBIBLE_API_KEY = 'k'; + const realFetch = globalThis.fetch; + let upstreamCalls = 0; + vi.stubGlobal('fetch', vi.fn(async (url, opts) => { + if (String(url).startsWith('http://localhost')) return realFetch(url, opts); // the test's own requests + upstreamCalls += 1; + await new Promise((r) => setTimeout(r, 30)); + return new Response(JSON.stringify({ data: 1 }), { status: 200, headers: { 'content-type': 'application/json' } }); + })); + const { registerIqBibleRoutes } = await import('./iqbible.js'); + const express = (await import('express')).default; + const app = express(); + app.use((req, _res, next) => { req.session = { userId: 'u1' }; next(); }); + registerIqBibleRoutes(app, (_req, _res, next) => next()); + const srv = await new Promise((resolve) => { const s = app.listen(0, () => resolve(s)); }); + const url = `http://localhost:${srv.address().port}/api/iq/devotionals/today`; + try { + const together = await Promise.all([realFetch(url), realFetch(url), realFetch(url)]); + expect(together.map((r) => r.status)).toEqual([200, 200, 200]); + expect(upstreamCalls).toBe(1); + await realFetch(url); // afterwards: not remembered, so it asks again + expect(upstreamCalls).toBe(2); + } finally { srv.close(); vi.unstubAllGlobals(); delete process.env.IQBIBLE_API_KEY; } +}); diff --git a/src/components/iq/IqNarratorOptions.jsx b/src/components/iq/IqNarratorOptions.jsx index aff90fb..4742c2c 100644 --- a/src/components/iq/IqNarratorOptions.jsx +++ b/src/components/iq/IqNarratorOptions.jsx @@ -1,11 +1,13 @@ // - + )} diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index 717b796..7f415ea 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -1,5 +1,5 @@ import { normalizeNtfyServer } from '../utils/ntfyServer.js'; -import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx'; +import IqNarratorOptions, { useLazyNarrators } from '../components/iq/IqNarratorOptions.jsx'; import { useEffect, useState } from 'react'; import { useApp } from '../context/AppContext.js'; import PageShell from '../components/study/PageShell.jsx'; @@ -65,6 +65,7 @@ export default function SettingsPage() { } = useApp(); // "Back up my data" from the ⌘K palette lands on that section + const narrators = useLazyNarrators(audioNarrator); const [tab, setTab] = useState(settingsFocus === 'backup' ? 'data' : 'account'); useEffect(() => { if (!settingsFocus) return; @@ -290,11 +291,11 @@ export default function SettingsPage() { - setAudioNarrator(e.target.value)} className={selectCls} {...narrators.selectProps}> - + diff --git a/src/utils/iqbible.js b/src/utils/iqbible.js index 76c9f4b..3e3efa9 100644 --- a/src/utils/iqbible.js +++ b/src/utils/iqbible.js @@ -2,7 +2,12 @@ // asked for; here we only build requests and keep answers for a few minutes so one open panel doesn't // repeat the same call. (Longer caching is the server's job and follows the API's licence terms.) const MEMO_MS = 5 * 60 * 1000; -const memo = new Map(); // key -> { at, promise } +// Today's verse and devotional are the same all day for everyone, so asking again within the hour is wasted. +// (Held in this tab's memory only; never saved.) +const DAILY = /\/(?:verse-of-the-day|devotionals\/today)(?:\?|$)/; +const DAILY_MS = 60 * 60 * 1000; +const FAILED_MS = 60 * 1000; // after "allowance used up" or "unreachable", don't ask again straight away +const memo = new Map(); // key -> { at, promise, ttl } let availability = null; export function iqAvailable() { @@ -27,7 +32,7 @@ export const iqUrl = buildPath; export function iqGet(path, query, { fresh = false } = {}) { const url = buildPath(path, query); const hit = memo.get(url); - if (!fresh && hit && Date.now() - hit.at < MEMO_MS) return hit.promise; + if (!fresh && hit && Date.now() - hit.at < hit.ttl) return hit.promise; const promise = fetch(url, { credentials: 'include' }) .then(async (res) => { const data = await res.json().catch(() => null); @@ -35,8 +40,15 @@ export function iqGet(path, query, { fresh = false } = {}) { return { ok: true, data }; }) .catch(() => ({ ok: false, status: null, error: 'Network error' })); - memo.set(url, { at: Date.now(), promise }); - promise.then((r) => { if (!r.ok) memo.delete(url); }); + const entry = { at: Date.now(), promise, ttl: DAILY.test(url) ? DAILY_MS : MEMO_MS }; + memo.set(url, entry); + promise.then((r) => { + if (r.ok) return; + // "Nothing here" (404) is a real answer and is kept like any other; a busy or unreachable service gets a + // minute's rest; anything else (a refused request) is forgotten so it can be tried again. + if (r.status === 429 || r.status === null || r.status >= 500) entry.ttl = FAILED_MS; + else if (r.status !== 404) memo.delete(url); + }); return promise; } diff --git a/src/utils/iqbible.memo.test.js b/src/utils/iqbible.memo.test.js new file mode 100644 index 0000000..1fcc44b --- /dev/null +++ b/src/utils/iqbible.memo.test.js @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { iqGet, resetIq } from './iqbible.js'; + +const answer = (status, body = {}) => ({ ok: status < 400, status, json: async () => body }); +beforeEach(() => { resetIq(); vi.useFakeTimers(); }); +afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); + +test('the same question is asked once, and the answer is reused for a few minutes', async () => { + const f = vi.fn(async () => answer(200, { data: 1 })); vi.stubGlobal('fetch', f); + await Promise.all([iqGet('geo/JHN/3'), iqGet('geo/JHN/3')]); + await iqGet('geo/JHN/3'); + expect(f).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(6 * 60 * 1000); + await iqGet('geo/JHN/3'); + expect(f).toHaveBeenCalledTimes(2); +}); + +test("today's verse and devotional are reused for the hour", async () => { + const f = vi.fn(async () => answer(200, { data: {} })); vi.stubGlobal('fetch', f); + await iqGet('bibles/eng_kjv/verse-of-the-day'); await iqGet('devotionals/today'); + vi.advanceTimersByTime(50 * 60 * 1000); + await iqGet('bibles/eng_kjv/verse-of-the-day'); await iqGet('devotionals/today'); + expect(f).toHaveBeenCalledTimes(2); + vi.advanceTimersByTime(11 * 60 * 1000); + await iqGet('devotionals/today'); + expect(f).toHaveBeenCalledTimes(3); +}); + +test('after the allowance runs out it waits a minute, but "nothing here" is kept like any answer', async () => { + const busy = vi.fn(async () => answer(429, { error: 'used up' })); vi.stubGlobal('fetch', busy); + expect((await iqGet('topics/JHN/3/16')).status).toBe(429); + await iqGet('topics/JHN/3/16'); + expect(busy).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(61_000); + await iqGet('topics/JHN/3/16'); + expect(busy).toHaveBeenCalledTimes(2); + + const none = vi.fn(async () => answer(404, { error: 'none' })); vi.stubGlobal('fetch', none); + await iqGet('geo/JHN/1'); await iqGet('geo/JHN/1'); + expect(none).toHaveBeenCalledTimes(1); + + const refused = vi.fn(async () => answer(400, { error: 'bad' })); vi.stubGlobal('fetch', refused); + await iqGet('geo/JHN/2'); await iqGet('geo/JHN/2'); + expect(refused).toHaveBeenCalledTimes(2); +});