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:
1 parent
d29a66f984
commit
31ac6b9179
7 files changed
+129
-17
No files matched your search
+21
-4
@@ -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.' });
|
||||
}
|
||||
|
||||
+28
-1
@@ -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; }
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
// <option>s for IQ Bible's narrated Bibles, to add inside an audio narrator <select>. Playing a
|
||||
// chapter this way uses one of the account's monthly audio streams, so the group says so.
|
||||
import { useState } from 'react';
|
||||
import { useIq, useIqAvailable } from '../../hooks/useIq.js';
|
||||
import { listOf } from '../../utils/iqbible.js';
|
||||
|
||||
export default function IqNarratorOptions() {
|
||||
// `enabled` holds the request back until someone opens the list (or an IQ narration is already chosen).
|
||||
export default function IqNarratorOptions({ enabled = true }) {
|
||||
const on = useIqAvailable();
|
||||
const { data } = useIq(on ? 'audio' : null);
|
||||
const { data } = useIq(on && enabled ? 'audio' : null);
|
||||
const sets = listOf(data).filter((s) => s.active !== false);
|
||||
if (!on || sets.length === 0) return null;
|
||||
return (
|
||||
@@ -14,3 +16,10 @@ export default function IqNarratorOptions() {
|
||||
</optgroup>
|
||||
);
|
||||
}
|
||||
|
||||
// For the <select> that holds these options: props to spread on it, and the `enabled` flag to pass along.
|
||||
export function useLazyNarrators(value) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const open = () => setOpened(true);
|
||||
return { enabled: opened || String(value ?? '').startsWith('iq:'), selectProps: { onFocus: open, onMouseDown: open, onTouchStart: open } };
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useApp } from '../context/AppContext.js';
|
||||
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
|
||||
import BookCover from '../components/BookCover.jsx';
|
||||
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
|
||||
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
|
||||
import IqNarratorOptions, { useLazyNarrators } from '../components/iq/IqNarratorOptions.jsx';
|
||||
import PageShell from '../components/study/PageShell.jsx';
|
||||
import AudioPlayer from '../components/AudioPlayer.jsx';
|
||||
import {
|
||||
@@ -49,6 +49,7 @@ export default function HomePage() {
|
||||
handleSeekAudio,
|
||||
handleSeekAudioTo,
|
||||
} = useApp();
|
||||
const narrators = useLazyNarrators(audioNarrator);
|
||||
const [homeSearch, setHomeSearch] = useState('');
|
||||
const [homeSort, setHomeSort] = useState(() => {
|
||||
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
|
||||
@@ -343,11 +344,11 @@ export default function HomePage() {
|
||||
<select value={audioBook} onChange={(e) => setAudioBook(e.target.value)} disabled={audioState.status === 'playing' || audioState.status === 'paused'} className={fieldCls}>
|
||||
{bookOptions.map((book) => <option key={book.abbrev} value={book.abbrev}>{book.name}</option>)}
|
||||
</select>
|
||||
<select value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} disabled={audioState.status === 'playing' || audioState.status === 'paused'} className={fieldCls}>
|
||||
<select value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} disabled={audioState.status === 'playing' || audioState.status === 'paused'} className={fieldCls} {...narrators.selectProps}>
|
||||
<option value="david">David</option>
|
||||
<option value="hays">Hays</option>
|
||||
<option value="souer">Souer</option>
|
||||
<IqNarratorOptions />
|
||||
<IqNarratorOptions enabled={narrators.enabled} />
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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() {
|
||||
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={readerWide} onChange={setReaderWide} />
|
||||
</PrefRow>
|
||||
<PrefRow label="Audio narrator" hint="For the Berean Standard Bible audio. IQ Bible narrations count against its monthly audio allowance.">
|
||||
<select aria-label="Audio narrator" value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} className={selectCls}>
|
||||
<select aria-label="Audio narrator" value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} className={selectCls} {...narrators.selectProps}>
|
||||
<option value="david">David</option>
|
||||
<option value="hays">Hays</option>
|
||||
<option value="souer">Souer</option>
|
||||
<IqNarratorOptions />
|
||||
<IqNarratorOptions enabled={narrators.enabled} />
|
||||
</select>
|
||||
</PrefRow>
|
||||
</section>
|
||||
|
||||
+16
-4
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user