fix(sidecar): upstream concurrency limiter, Yahoo rate gate, startup batching (#1145)

- Sidecar: add global concurrency limiter (max 6 concurrent upstream requests)
- Sidecar: add Yahoo Finance rate gate (600ms spacing) in fetch patch
- Sidecar: fix default remoteBase to api.worldmonitor.app
- data-loader: stagger startup tasks in batches of 4 with 300ms delay
- get-country-stock-index: add yahooGate() before Yahoo fetch
- tauri.conf: add titleBarStyle Overlay
This commit is contained in:
Elie Habib
2026-03-06 23:45:23 +04:00
committed by GitHub
parent 26ecf3d91d
commit 6ccda09246
4 changed files with 85 additions and 38 deletions

View File

@@ -59,43 +59,80 @@ function isTransientVerificationError(error) {
return /timed out|timeout|network|fetch failed|failed to fetch|socket hang up/i.test(error.message);
}
// Global concurrency limiter for upstream requests.
let _activeUpstream = 0;
const _upstreamQueue = [];
const MAX_CONCURRENT_UPSTREAM = 6;
function acquireUpstreamSlot() {
if (_activeUpstream < MAX_CONCURRENT_UPSTREAM) {
_activeUpstream++;
return Promise.resolve();
}
return new Promise(resolve => _upstreamQueue.push(resolve));
}
function releaseUpstreamSlot() {
if (_upstreamQueue.length > 0) {
_upstreamQueue.shift()();
} else {
_activeUpstream--;
}
}
// Global Yahoo Finance rate gate — shared across ALL handler bundles.
let _yahooLastReq = 0;
let _yahooQueue = Promise.resolve();
function sidecarYahooGate() {
_yahooQueue = _yahooQueue.then(async () => {
const elapsed = Date.now() - _yahooLastReq;
if (elapsed < 600) await new Promise(r => setTimeout(r, 600 - elapsed));
_yahooLastReq = Date.now();
});
return _yahooQueue;
}
globalThis.fetch = async function ipv4Fetch(input, init) {
const isRequest = input && typeof input === 'object' && 'url' in input;
let url;
try { url = new URL(typeof input === 'string' ? input : input.url); } catch { return _originalFetch(input, init); }
if (url.protocol !== 'https:' && url.protocol !== 'http:') return _originalFetch(input, init);
const mod = url.protocol === 'https:' ? https : http;
const method = init?.method || (isRequest ? input.method : 'GET');
const body = await resolveRequestBody(input, init, method, isRequest);
const headers = {};
const rawHeaders = init?.headers || (isRequest ? input.headers : null);
if (rawHeaders) {
const h = rawHeaders instanceof Headers ? Object.fromEntries(rawHeaders.entries())
: Array.isArray(rawHeaders) ? Object.fromEntries(rawHeaders) : rawHeaders;
Object.assign(headers, h);
}
return new Promise((resolve, reject) => {
const req = mod.request({ hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname + url.search, method, headers, family: 4 }, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const buf = Buffer.concat(chunks);
const responseHeaders = new Headers();
for (const [k, v] of Object.entries(res.headers)) {
if (v) responseHeaders.set(k, Array.isArray(v) ? v.join(', ') : v);
}
try {
resolve(buildSafeResponse(res.statusCode, res.statusMessage, responseHeaders, buf));
} catch (error) {
reject(error);
}
if (url.hostname.includes('finance.yahoo.com')) await sidecarYahooGate();
await acquireUpstreamSlot();
try {
const mod = url.protocol === 'https:' ? https : http;
const method = init?.method || (isRequest ? input.method : 'GET');
const body = await resolveRequestBody(input, init, method, isRequest);
const headers = {};
const rawHeaders = init?.headers || (isRequest ? input.headers : null);
if (rawHeaders) {
const h = rawHeaders instanceof Headers ? Object.fromEntries(rawHeaders.entries())
: Array.isArray(rawHeaders) ? Object.fromEntries(rawHeaders) : rawHeaders;
Object.assign(headers, h);
}
return await new Promise((resolve, reject) => {
const req = mod.request({ hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname + url.search, method, headers, family: 4 }, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const buf = Buffer.concat(chunks);
const responseHeaders = new Headers();
for (const [k, v] of Object.entries(res.headers)) {
if (v) responseHeaders.set(k, Array.isArray(v) ? v.join(', ') : v);
}
try {
resolve(buildSafeResponse(res.statusCode, res.statusMessage, responseHeaders, buf));
} catch (error) {
reject(error);
}
});
});
req.on('error', reject);
if (init?.signal) { init.signal.addEventListener('abort', () => req.destroy()); }
if (body != null) req.write(body);
req.end();
});
req.on('error', reject);
if (init?.signal) { init.signal.addEventListener('abort', () => req.destroy()); }
if (body != null) req.write(body);
req.end();
});
} finally {
releaseUpstreamSlot();
}
};
const ALLOWED_ENV_KEYS = new Set([
@@ -452,7 +489,7 @@ async function importHandler(modulePath) {
function resolveConfig(options = {}) {
const port = Number(options.port ?? process.env.LOCAL_API_PORT ?? 46123);
const remoteBase = String(options.remoteBase ?? process.env.LOCAL_API_REMOTE_BASE ?? 'https://worldmonitor.app').replace(/\/$/, '');
const remoteBase = String(options.remoteBase ?? process.env.LOCAL_API_REMOTE_BASE ?? 'https://api.worldmonitor.app').replace(/\/$/, '');
const resourceDir = String(options.resourceDir ?? process.env.LOCAL_API_RESOURCE_DIR ?? process.cwd());
const apiDir = options.apiDir
? String(options.apiDir)

View File

@@ -14,6 +14,7 @@
"windows": [
{
"title": "World Monitor",
"titleBarStyle": "Overlay",
"width": 1440,
"height": 900,
"minWidth": 1200,