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; }
|
||||
});
|
||||
Reference in new issue
Block a user