Addressed freezing indefinitely when requests are aborted.
This commit is contained in:
@@ -60,6 +60,13 @@ module.exports = {
|
|||||||
avoidDuplicates: true,
|
avoidDuplicates: true,
|
||||||
retries: 3,
|
retries: 3,
|
||||||
concurrency: 10,
|
concurrency: 10,
|
||||||
|
// wall-clock ceiling (ms) for a single item-download attempt (src/fetch/item.js).
|
||||||
|
// bhttp has no timeout of its own - a request that connects but then just stops
|
||||||
|
// responding (a stalled/misbehaving proxy tunnel is a common cause) would otherwise
|
||||||
|
// hang forever, and since the limiter's concurrency is capped, that one stuck request
|
||||||
|
// blocks every other download queued behind it indefinitely. This bounds each attempt
|
||||||
|
// so a stall fails (and retries, per `retries` above) instead of hanging forever.
|
||||||
|
timeout: 60000,
|
||||||
watch: {
|
watch: {
|
||||||
schedule: '*/30 * * * *',
|
schedule: '*/30 * * * *',
|
||||||
},
|
},
|
||||||
@@ -102,6 +109,13 @@ module.exports = {
|
|||||||
proxy: {
|
proxy: {
|
||||||
use: true,
|
use: true,
|
||||||
default: null,
|
default: null,
|
||||||
|
// if a request fails (including timing out - see fetch.timeout above) and is about to
|
||||||
|
// be retried, retry it using whichever of proxied/direct is NOT what would normally
|
||||||
|
// apply for that host - e.g. the proxy itself is down or being blocked by the target
|
||||||
|
// site, or (the reverse) a host reachable directly is refusing unproxied traffic.
|
||||||
|
// Off by default: for a privacy-sensitive proxy (e.g. Tor) silently falling back to a
|
||||||
|
// direct connection on failure is very much not what you want.
|
||||||
|
retryFlipped: false,
|
||||||
hosts: {
|
hosts: {
|
||||||
// hostnames are matched by suffix, so e.g. 'reddit.com' also covers
|
// hostnames are matched by suffix, so e.g. 'reddit.com' also covers
|
||||||
// 'www.reddit.com', but not 'v.redd.it' or 'redd.it' - those need their own
|
// 'www.reddit.com', but not 'v.redd.it' or 'redd.it' - those need their own
|
||||||
|
|||||||
@@ -10,6 +10,21 @@ const { agentFor } = require('../proxy');
|
|||||||
const logger = require('../logger')(__filename);
|
const logger = require('../logger')(__filename);
|
||||||
const limiter = require('../limiter').items;
|
const limiter = require('../limiter').items;
|
||||||
|
|
||||||
|
// bhttp has no timeout at all by default - if the server (or, more likely, a proxy tunnel
|
||||||
|
// that connects fine but then never relays anything) just stops responding mid-request without
|
||||||
|
// erroring, the returned promise never settles. With the limiter's concurrency capped, that one
|
||||||
|
// stuck request blocks every other download queued behind it, indefinitely. Race it against a
|
||||||
|
// hard wall-clock ceiling so a stall fails (and retries, below) instead of hanging forever.
|
||||||
|
function withTimeout(promise, ms, url) {
|
||||||
|
let timer;
|
||||||
|
|
||||||
|
const timedOut = new Promise((resolve, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error(`Timed out after ${ms}ms fetching '${url}'`)), ms);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.race([promise, timedOut]).finally(() => clearTimeout(timer));
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchItem(url, attempt, context) {
|
async function fetchItem(url, attempt, context) {
|
||||||
async function retry(error) {
|
async function retry(error) {
|
||||||
logger.warn(`Failed to fetch '${url}', ${attempt < config.fetch.retries ? 'retrying' : 'giving up'}: ${error.message} (${context.post ? context.post.permalink : 'no post'})`);
|
logger.warn(`Failed to fetch '${url}', ${attempt < config.fetch.retries ? 'retrying' : 'giving up'}: ${error.message} (${context.post ? context.post.permalink : 'no post'})`);
|
||||||
@@ -23,7 +38,15 @@ async function fetchItem(url, attempt, context) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// throw new Error('Failed it!');
|
// throw new Error('Failed it!');
|
||||||
const res = await limiter.schedule(async () => bhttp.get(url, { headers: context.headers, agent: agentFor(url) }));
|
// config.proxy.retryFlipped: once this url has already failed once, retry it via
|
||||||
|
// whichever of proxied/direct is NOT what it'd normally get - see resolveProxyUrl()
|
||||||
|
const flip = config.proxy.retryFlipped && attempt > 0;
|
||||||
|
|
||||||
|
const res = await limiter.schedule(async () => withTimeout(
|
||||||
|
bhttp.get(url, { headers: context.headers, agent: agentFor(url, { flip }) }),
|
||||||
|
config.fetch.timeout,
|
||||||
|
url,
|
||||||
|
));
|
||||||
|
|
||||||
if (res.statusCode !== 200) {
|
if (res.statusCode !== 200) {
|
||||||
throw new Error(`Response not OK for ${url} (${res.statusCode})`);
|
throw new Error(`Response not OK for ${url} (${res.statusCode})`);
|
||||||
|
|||||||
66
src/proxy.js
66
src/proxy.js
@@ -21,7 +21,7 @@ function hostMatches(hostname, pattern) {
|
|||||||
return hostname === normalized || hostname.endsWith(`.${normalized}`);
|
return hostname === normalized || hostname.endsWith(`.${normalized}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProxyUrl(hostname) {
|
function resolveProxyUrl(hostname, { flip = false } = {}) {
|
||||||
if (config.proxy?.use === false) {
|
if (config.proxy?.use === false) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -29,19 +29,32 @@ function resolveProxyUrl(hostname) {
|
|||||||
const hosts = config.proxy?.hosts || {};
|
const hosts = config.proxy?.hosts || {};
|
||||||
const matchedPattern = Object.keys(hosts).find((pattern) => hostMatches(hostname, pattern));
|
const matchedPattern = Object.keys(hosts).find((pattern) => hostMatches(hostname, pattern));
|
||||||
|
|
||||||
if (!matchedPattern) {
|
const normal = (() => {
|
||||||
return config.proxy?.default || null;
|
if (!matchedPattern) {
|
||||||
|
return config.proxy?.default || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = hosts[matchedPattern];
|
||||||
|
|
||||||
|
// `true` explicitly opts this host into `default`, `null`/`false`/'' explicitly opts it
|
||||||
|
// out (rather than falling through to `default`), anything else is a per-host override
|
||||||
|
if (matched === true) {
|
||||||
|
return config.proxy?.default || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched || null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!flip) {
|
||||||
|
return normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
const matched = hosts[matchedPattern];
|
// used for config.proxy.retryFlipped: retry via whichever of proxied/direct is NOT what
|
||||||
|
// this host would normally get. A request that would normally be proxied (whether via
|
||||||
// `true` explicitly opts this host into `default`, `null`/`false`/'' explicitly opts it
|
// `default` or a per-host override) retries direct; a request that would normally go
|
||||||
// out (rather than falling through to `default`), anything else is a per-host override
|
// direct (no proxy configured for this host, or explicitly excluded from `default`)
|
||||||
if (matched === true) {
|
// retries through `default`, if one is set.
|
||||||
return config.proxy?.default || null;
|
return normal ? null : (config.proxy?.default || null);
|
||||||
}
|
|
||||||
|
|
||||||
return matched || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 'socks4a'/'socks5h' (as opposed to plain 'socks4'/'socks5') tell socks-proxy-agent to
|
// 'socks4a'/'socks5h' (as opposed to plain 'socks4'/'socks5') tell socks-proxy-agent to
|
||||||
@@ -55,9 +68,9 @@ function isSocks(proxyUrl) {
|
|||||||
// undici (which node's built-in fetch() is implemented with) only tunnels through http(s)
|
// undici (which node's built-in fetch() is implemented with) only tunnels through http(s)
|
||||||
// proxies (CONNECT) via ProxyAgent - it has no built-in SOCKS support. agentFor() below can
|
// proxies (CONNECT) via ProxyAgent - it has no built-in SOCKS support. agentFor() below can
|
||||||
// still do SOCKS, since bhttp goes through node's plain http/https modules instead.
|
// still do SOCKS, since bhttp goes through node's plain http/https modules instead.
|
||||||
function dispatcherFor(targetUrl) {
|
function dispatcherFor(targetUrl, { flip = false } = {}) {
|
||||||
const { hostname } = new URL(targetUrl);
|
const { hostname } = new URL(targetUrl);
|
||||||
const proxyUrl = resolveProxyUrl(hostname);
|
const proxyUrl = resolveProxyUrl(hostname, { flip });
|
||||||
|
|
||||||
if (!proxyUrl) {
|
if (!proxyUrl) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -81,9 +94,9 @@ function dispatcherFor(targetUrl) {
|
|||||||
|
|
||||||
// bhttp uses node's raw http/https modules under the hood, which take a plain Agent
|
// bhttp uses node's raw http/https modules under the hood, which take a plain Agent
|
||||||
// instance - unlike fetch() above, this does support SOCKS
|
// instance - unlike fetch() above, this does support SOCKS
|
||||||
function agentFor(targetUrl) {
|
function agentFor(targetUrl, { flip = false } = {}) {
|
||||||
const url = new URL(targetUrl);
|
const url = new URL(targetUrl);
|
||||||
const proxyUrl = resolveProxyUrl(url.hostname);
|
const proxyUrl = resolveProxyUrl(url.hostname, { flip });
|
||||||
|
|
||||||
if (!proxyUrl) {
|
if (!proxyUrl) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -105,11 +118,26 @@ function agentFor(targetUrl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// drop-in replacement for the global fetch() - importing this as `fetch` transparently
|
// drop-in replacement for the global fetch() - importing this as `fetch` transparently
|
||||||
// proxies requests per config.proxy, if one is configured for the request's host
|
// proxies requests per config.proxy, if one is configured for the request's host. Also
|
||||||
|
// applies a hard wall-clock ceiling (config.fetch.timeout) to every request: unlike bhttp,
|
||||||
|
// fetch() doesn't hang *forever* on a stalled/black-holed connection (undici defaults to a
|
||||||
|
// 300s headers/body timeout), but that default is high enough - and, per live testing, a
|
||||||
|
// proxy CONNECT tunnel that never completes can still take well over 20s to surface any
|
||||||
|
// error - to look like a freeze, especially since reddit/client.js's requestJson() serializes
|
||||||
|
// all reddit API calls behind a single-concurrency limiter. Bounding it here covers every
|
||||||
|
// fetch()-based call site (reddit's API, and every hosting site's info-lookup) at once.
|
||||||
|
// Pass `proxyFlip: true` in options (stripped before reaching fetch()) to use whichever of
|
||||||
|
// proxied/direct is NOT what this host would normally get - see resolveProxyUrl().
|
||||||
async function proxiedFetch(url, options = {}) {
|
async function proxiedFetch(url, options = {}) {
|
||||||
const dispatcher = dispatcherFor(String(url));
|
const { proxyFlip, signal, ...fetchOptions } = options;
|
||||||
|
const dispatcher = dispatcherFor(String(url), { flip: proxyFlip });
|
||||||
|
const timeoutSignal = AbortSignal.timeout(config.fetch.timeout);
|
||||||
|
|
||||||
return fetch(url, dispatcher ? { ...options, dispatcher } : options);
|
return fetch(url, {
|
||||||
|
...fetchOptions,
|
||||||
|
...(dispatcher ? { dispatcher } : {}),
|
||||||
|
signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -111,43 +111,69 @@ async function wait(seconds) {
|
|||||||
async function requestJson(path, params = {}, attempt = 0) {
|
async function requestJson(path, params = {}, attempt = 0) {
|
||||||
const url = buildUrl(path, params);
|
const url = buildUrl(path, params);
|
||||||
|
|
||||||
return limiter.schedule(async () => {
|
// Only the actual throttled network call belongs inside limiter.schedule() - this has
|
||||||
const res = await fetch(url.toString(), { headers: headers() });
|
// maxConcurrent: 1, and every retry path below used to recurse back into requestJson()
|
||||||
const contentType = res.headers.get('content-type') || '';
|
// (and so back into limiter.schedule()) from *inside* that same callback. Bottleneck won't
|
||||||
|
// start a second job while the first is still running, and the first can't finish until
|
||||||
|
// its awaited recursive call starts - a guaranteed deadlock the instant a retry was ever
|
||||||
|
// needed (e.g. a single 429), freezing every reddit API call behind it for good. Retrying
|
||||||
|
// now happens here, after the scheduled call has actually returned and freed its slot.
|
||||||
|
let res;
|
||||||
|
|
||||||
if (res.status === 429 || res.status === 503) {
|
try {
|
||||||
if (attempt >= MAX_RETRIES) {
|
// config.proxy.retryFlipped: once this request has already failed once, retry it via
|
||||||
throw new Error(`Reddit rate-limited request after ${attempt} retries (${res.status}) for '${path}'`);
|
// whichever of proxied/direct is NOT what it'd normally get - see proxy.js
|
||||||
}
|
res = await limiter.schedule(async () => fetch(url.toString(), {
|
||||||
|
headers: headers(),
|
||||||
const retryAfter = Number(res.headers.get('retry-after')) || 2 ** attempt;
|
proxyFlip: config.proxy.retryFlipped && attempt > 0,
|
||||||
|
}));
|
||||||
logger.warn(`Rate limited by reddit (${res.status}) for '${path}', retrying in ${retryAfter}s`);
|
} catch (error) {
|
||||||
|
// fetch() has no timeout of its own beyond undici's fairly generous defaults (see
|
||||||
await wait(retryAfter);
|
// proxy.js) - this catches both that and genuine network errors (DNS, connection
|
||||||
|
// refused/reset, etc.), neither of which was retried before
|
||||||
return requestJson(path, params, attempt + 1);
|
if (attempt >= MAX_RETRIES) {
|
||||||
|
throw new Error(`Failed to reach reddit after ${attempt} retries for '${path}': ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!contentType.includes('json')) {
|
logger.warn(`Failed to reach reddit for '${path}', retrying: ${error.message}`);
|
||||||
// reddit serves an HTML interstitial/block page instead of JSON when it decides
|
|
||||||
// the request looks like a bot - this is a distinct failure mode from a genuine
|
|
||||||
// 4xx and should be logged/handled separately rather than treated as "bad request"
|
|
||||||
const hint = cookieHeader() ? ' - session cookie may have expired, refresh config/cookies.js' : ' - likely blocked or interstitial page (consider setting config.reddit.cookies)';
|
|
||||||
|
|
||||||
throw new Error(`Reddit returned non-JSON response (${res.status}, content-type '${contentType || 'none'}') for '${path}'${hint}`);
|
return requestJson(path, params, attempt + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = res.headers.get('content-type') || '';
|
||||||
|
|
||||||
|
if (res.status === 429 || res.status === 503) {
|
||||||
|
if (attempt >= MAX_RETRIES) {
|
||||||
|
throw new Error(`Reddit rate-limited request after ${attempt} retries (${res.status}) for '${path}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const retryAfter = Number(res.headers.get('retry-after')) || 2 ** attempt;
|
||||||
|
|
||||||
if (!res.ok) {
|
logger.warn(`Rate limited by reddit (${res.status}) for '${path}', retrying in ${retryAfter}s`);
|
||||||
const message = data?.message || data?.reason || JSON.stringify(data).slice(0, 200);
|
|
||||||
|
|
||||||
throw new Error(`Reddit returned ${res.status} for '${path}': ${message}`);
|
await wait(retryAfter);
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
return requestJson(path, params, attempt + 1);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if (!contentType.includes('json')) {
|
||||||
|
// reddit serves an HTML interstitial/block page instead of JSON when it decides
|
||||||
|
// the request looks like a bot - this is a distinct failure mode from a genuine
|
||||||
|
// 4xx and should be logged/handled separately rather than treated as "bad request"
|
||||||
|
const hint = cookieHeader() ? ' - session cookie may have expired, refresh config/cookies.js' : ' - likely blocked or interstitial page (consider setting config.reddit.cookies)';
|
||||||
|
|
||||||
|
throw new Error(`Reddit returned non-JSON response (${res.status}, content-type '${contentType || 'none'}') for '${path}'${hint}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const message = data?.message || data?.reason || JSON.stringify(data).slice(0, 200);
|
||||||
|
|
||||||
|
throw new Error(`Reddit returned ${res.status} for '${path}': ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getUser(username) {
|
async function getUser(username) {
|
||||||
|
|||||||
Reference in New Issue
Block a user