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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 13:25:51 -04:00
1 parent d29a66f984
commit 31ac6b9179
7 files changed
+129 -17

No files matched your search

+16 -4
View File
@@ -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;
}
+45
View File
@@ -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);
});