diff --git a/README.md b/README.md index 76c3576..4eec4e9 100644 --- a/README.md +++ b/README.md @@ -484,6 +484,8 @@ Try the Web MCP without any setup: | `POLLING_TIMEOUT` | Timeout for web_data_* tools polling (seconds) | `600` | `300`, `1200` | | `BASE_TIMEOUT` | Request timeout for base tools in seconds (search & scrape) | No limit | `60`, `120` | | `BASE_MAX_RETRIES` | Max retries for base tools on transient errors (0-3) | `0` | `1`, `3` | +| `BASE_BACKOFF_MS` | Starting backoff (ms) between retries (doubles each attempt, with jitter) | `500` | `250`, `1000` | +| `MAX_BACKOFF_MS` | Upper cap (ms) on a single backoff wait | `30000` | `5000`, `10000` | | `GROUPS` | Comma-separated tool group IDs | - | `ecommerce,browser` | | `TOOLS` | Comma-separated individual tool names | - | `extract,scrape_as_html` | @@ -492,6 +494,11 @@ Try the Web MCP without any setup: - Lower values (e.g., 300) will fail faster on slow data collections. - Higher values (e.g., 1200) allow more time for complex scraping tasks. +**Retry and backoff (base tools):** +- When `BASE_MAX_RETRIES` is above `0`, transient gateway failures (502/504/503/500/408, network resets, and 429 rate limits) are retried. Permanent outcomes (4xx client errors, 403/451 blocks, 3xx redirects) are never retried. +- Each retry waits an exponential, jittered backoff that starts at `BASE_BACKOFF_MS` and doubles per attempt, capped at `MAX_BACKOFF_MS`. A server `Retry-After` header (when present) takes precedence, also capped at `MAX_BACKOFF_MS`. +- This means a single call can now block for tens of seconds before it ultimately fails. With the defaults (`BASE_MAX_RETRIES` up to `3`, `MAX_BACKOFF_MS` of `30000`), the worst case adds up to roughly 90 seconds of backoff (three waits capped at 30s each) on top of the request timeouts. Lower `MAX_BACKOFF_MS` (and/or `BASE_MAX_RETRIES`) if you need calls to fail faster. + --- ## 📚 Documentation diff --git a/package.json b/package.json index ec23edd..877d67e 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "server.js", "search_utils.js", "search_dataset_schema.js", + "retry_utils.js", "browser_tools.js", "browser_session.js", "aria_snapshot_filter.js", diff --git a/retry_utils.js b/retry_utils.js new file mode 100644 index 0000000..9858ba5 --- /dev/null +++ b/retry_utils.js @@ -0,0 +1,245 @@ +'use strict'; /*jslint node:true es9:true*/ + +// Pure, side-effect-free helpers for classifying Bright Data responses and +// computing retry/backoff delays. Extracted so the retry policy is testable in +// isolation and reusable across tools (search, scrape, batch, web_data). +// See issue #104 (intermittent 502/504 from the gateway, no retry guidance). + +// Outcome taxonomy returned by classify_response. Each value tells the caller +// what to do next, independent of any particular transport library. +export const OUTCOME = { + SUCCESS: 'success', // 2xx - use the body + REDIRECT: 'redirect', // 3xx - follow the Location (not an error) + RETRYABLE: 'retryable', // transient - safe to retry after a backoff + RATE_LIMITED: 'rate_limited', // 429 - retry, but honor Retry-After if given + BLOCKED: 'blocked', // target actively blocked us (403/451) - terminal + CLIENT_ERROR: 'client_error', // 4xx caller mistake - terminal, do not retry + FATAL: 'fatal', // unexpected/unclassifiable - terminal +}; + +// Gateway/transport statuses that are safe to retry. 502/504 are the exact +// symptoms reported in issue #104; 408/425 are slow/early-data conditions and +// 500/503 are transient server states. +const RETRYABLE_STATUS = new Set([408, 425, 500, 502, 503, 504]); + +// Statuses that mean "the target refused us"; retrying the same request will +// not help, so we surface them as a first-class BLOCKED outcome rather than +// burning retries or discarding the signal. +const BLOCKED_STATUS = new Set([403, 451]); + +// Node/undici/axios network error codes with no HTTP status attached. These are +// transient connectivity failures and are safe to retry. +const RETRYABLE_NETWORK_CODES = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'ECONNABORTED', + 'ETIMEDOUT', + 'EAI_AGAIN', + 'EPIPE', + 'ENETUNREACH', + 'ENETRESET', + 'EHOSTUNREACH', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_HEADERS_TIMEOUT', + 'UND_ERR_SOCKET', +]); + +function is_finite_number(value){ + return typeof value=='number' && Number.isFinite(value); +} + +// An HTTP-date per RFC 9110: IMF-fixdate ("Sun, 06 Nov 1994 08:49:37 GMT"), +// rfc850-date ("Sunday, 06-Nov-94 08:49:37 GMT"), or asctime +// ("Sun Nov 6 08:49:37 1994"). We require a recognizable day-name prefix so a +// bare number-like string ('1.5', '-3') is NEVER fed to the permissive +// Date.parse (which would read it as a past date and clamp to an immediate retry). +const HTTP_DATE_RE = + /^(mon|tue|wed|thu|fri|sat|sun)[a-z]*[,\s]/i; + +// Parse a Retry-After header value (RFC 9110). It is either a non-negative +// integer number of seconds or an HTTP-date. Returns milliseconds, or null if +// absent/malformed/in the past. A malformed value (fractional '1.5', negative +// '-3', junk) returns null so the caller falls back to its computed backoff +// rather than retrying immediately. `now_ms` is injectable for deterministic tests. +export function parse_retry_after(value, now_ms = Date.now()){ + if (value===undefined || value===null) + return null; + const raw = String(value).trim(); + if (!raw) + return null; + // (a) a non-negative integer number of seconds. + if (/^\d+$/.test(raw)) + { + const seconds = parseInt(raw, 10); + return seconds * 1000; + } + // (b) a valid HTTP-date. Reject anything that is not date-shaped up front so + // permissive Date.parse never silently accepts numeric junk as a past date. + if (!HTTP_DATE_RE.test(raw)) + return null; + const when = Date.parse(raw); + if (Number.isNaN(when)) + return null; + const delta = when - now_ms; + return delta > 0 ? delta : 0; +} + +// Read a header case-insensitively from a plain object. Bright Data / undici may +// return header names in any case, so we never assume a fixed casing. +function get_header(headers, name){ + if (!headers || typeof headers!='object') + return undefined; + const target = name.toLowerCase(); + for (const key of Object.keys(headers)) + { + if (key.toLowerCase()===target) + return headers[key]; + } + return undefined; +} + +// Classify a response or thrown error into a stable OUTCOME plus the metadata a +// retry loop needs. Accepts a normalized shape so it never depends on axios: +// {status, headers} - a completed HTTP response, or +// {error: {code, response}} - a thrown transport error (axios-style). +// Returns {outcome, status|null, retry_after_ms|null, retryable, reason}. +export function classify_response(input, now_ms = Date.now()){ + const obj = input && typeof input=='object' ? input : {}; + const err = obj.error && typeof obj.error=='object' ? obj.error : null; + + // A thrown error may carry an HTTP response (server replied with a status) + // or only a network code (connection never completed). + const response = err ? err.response : obj; + const status = response && is_finite_number(response.status) + ? response.status : null; + const headers = response ? response.headers : undefined; + const retry_after_ms = parse_retry_after(get_header(headers, 'retry-after'), + now_ms); + + if (status===null) + { + const code = err && typeof err.code=='string' ? err.code : null; + if (code && RETRYABLE_NETWORK_CODES.has(code)) + { + return {outcome: OUTCOME.RETRYABLE, status: null, + retry_after_ms: null, retryable: true, + reason: `network error ${code}`}; + } + return {outcome: OUTCOME.FATAL, status: null, retry_after_ms: null, + retryable: false, + reason: code ? `unhandled network error ${code}` + : 'no status and no network code'}; + } + + if (status>=200 && status<300) + { + return {outcome: OUTCOME.SUCCESS, status, retry_after_ms: null, + retryable: false, reason: `http ${status}`}; + } + + // 3xx: a redirect, not an error. axios follows these transparently, so one + // surfacing here is a terminal-for-this-call signal to follow/report. It is + // neither a retryable gateway error nor a hard fatal, hence its own outcome. + if (status>=300 && status<400) + { + return {outcome: OUTCOME.REDIRECT, status, retry_after_ms: null, + retryable: false, reason: `http ${status} redirect`}; + } + + if (status===429) + { + return {outcome: OUTCOME.RATE_LIMITED, status, retry_after_ms, + retryable: true, reason: 'http 429 rate limited'}; + } + + if (BLOCKED_STATUS.has(status)) + { + return {outcome: OUTCOME.BLOCKED, status, retry_after_ms: null, + retryable: false, reason: `http ${status} target blocked request`}; + } + + if (RETRYABLE_STATUS.has(status)) + { + return {outcome: OUTCOME.RETRYABLE, status, retry_after_ms, + retryable: true, reason: `http ${status} transient gateway error`}; + } + + if (status>=400 && status<500) + { + return {outcome: OUTCOME.CLIENT_ERROR, status, retry_after_ms: null, + retryable: false, reason: `http ${status} client error`}; + } + + // Any other 5xx we did not enumerate: treat as retryable (transient by + // nature) rather than fatal, but cap via the caller's max_retries. + if (status>=500) + { + return {outcome: OUTCOME.RETRYABLE, status, retry_after_ms, + retryable: true, reason: `http ${status} server error`}; + } + + return {outcome: OUTCOME.FATAL, status, retry_after_ms: null, + retryable: false, reason: `http ${status} unclassified`}; +} + +// Default exponential-backoff parameters. base_ms doubles each attempt up to +// max_ms, then full jitter is applied so concurrent callers (issue #104's burst +// of 50+ calls) do not retry in lockstep and re-overload the gateway. +export const DEFAULT_BACKOFF = { + base_ms: 500, + max_ms: 30000, + factor: 2, + jitter: 'full', +}; + +// Compute the delay (ms) before retry `attempt` (0-indexed: attempt 0 is the +// wait before the 2nd try). A server-supplied retry_after_ms always wins and is +// clamped to max_ms. `rng` is injectable (defaults to Math.random) so jittered +// delays are deterministic under test. +export function compute_backoff(attempt, opts = {}, rng = Math.random){ + const base_ms = is_finite_number(opts.base_ms) && opts.base_ms>=0 + ? opts.base_ms : DEFAULT_BACKOFF.base_ms; + const max_ms = is_finite_number(opts.max_ms) && opts.max_ms>=0 + ? opts.max_ms : DEFAULT_BACKOFF.max_ms; + const factor = is_finite_number(opts.factor) && opts.factor>=1 + ? opts.factor : DEFAULT_BACKOFF.factor; + const jitter = opts.jitter===undefined ? DEFAULT_BACKOFF.jitter + : opts.jitter; + + if (is_finite_number(opts.retry_after_ms) && opts.retry_after_ms>=0) + return Math.min(opts.retry_after_ms, max_ms); + + const safe_attempt = is_finite_number(attempt) && attempt>0 + ? Math.floor(attempt) : 0; + const exponential = base_ms * Math.pow(factor, safe_attempt); + const capped = Math.min(exponential, max_ms); + + if (jitter==='none') + return capped; + if (jitter==='equal') + { + // AWS "equal jitter": half fixed, half random. + const half = capped / 2; + return Math.round(half + rng() * half); + } + // "full jitter" (default): uniform random in [0, capped]. + return Math.round(rng() * capped); +} + +// Decide whether to retry given a classification and how many attempts remain. +// `attempt` is 0-indexed (0 = the first try just failed). Returns +// {retry, delay_ms, classification} so a loop has everything it needs. +export function should_retry(classification, attempt, max_retries, + opts = {}, rng = Math.random){ + const safe_max = is_finite_number(max_retries) && max_retries>=0 + ? Math.floor(max_retries) : 0; + if (!classification || !classification.retryable) + return {retry: false, delay_ms: 0, classification}; + if (attempt>=safe_max) + return {retry: false, delay_ms: 0, classification}; + const delay_ms = compute_backoff(attempt, { + ...opts, + retry_after_ms: classification.retry_after_ms ?? undefined, + }, rng); + return {retry: true, delay_ms, classification}; +} diff --git a/server.js b/server.js index 19f85ac..42024af 100644 --- a/server.js +++ b/server.js @@ -9,6 +9,7 @@ import {GROUPS} from './tool_groups.js'; import {parse_google_search_response} from './search_utils.js'; import {dataset_id_schema, filter_schema, metadata_to_fields, FILTER_OPERATORS} from './search_dataset_schema.js'; +import {classify_response, should_retry} from './retry_utils.js'; import {createRequire} from 'node:module'; import {remark} from 'remark'; import strip from 'strip-markdown'; @@ -21,8 +22,8 @@ const pro_mode = process.env.PRO_MODE === 'true'; const polling_timeout = parseInt(process.env.POLLING_TIMEOUT || '600', 10); const base_timeout = process.env.BASE_TIMEOUT ? parseInt(process.env.BASE_TIMEOUT, 10) * 1000 : 0; -const base_max_retries = Math.min( - parseInt(process.env.BASE_MAX_RETRIES || '0', 10), 3); +const base_max_retries = Math.max(0, + Math.min(parseInt(process.env.BASE_MAX_RETRIES || '0', 10) || 0, 3)); const pro_mode_tools = ['search_engine', 'scrape_as_markdown', 'search_engine_batch', 'scrape_batch', 'discover']; const tool_groups = process.env.GROUPS ? @@ -71,21 +72,53 @@ const rate_limit_config = parse_rate_limit(process.env.RATE_LIMIT); if (!api_token) throw new Error('Cannot run MCP server without API_TOKEN env'); +const sleep = ms=>new Promise(resolve=>setTimeout(resolve, ms)); + +// Backoff knobs (overridable via env) used by base_request. +// Issue #104: bursts of MCP calls hit intermittent 502/504 from the gateway and +// there was no backoff guidance. We now classify each failure and retry only the +// transient ones with exponential backoff + full jitter, honoring Retry-After. +const backoff_opts = { + base_ms: parseInt(process.env.BASE_BACKOFF_MS || '500', 10), + max_ms: parseInt(process.env.MAX_BACKOFF_MS || '30000', 10), + factor: 2, + jitter: 'full', +}; + async function base_request(config){ let last_err; + let retries = 0; + let total_delay_ms = 0; for (let attempt = 0; attempt <= base_max_retries; attempt++) { try { return await axios({...config, timeout: base_timeout}); } catch(e){ last_err = e; - if (e.response?.status && e.response.status >= 400 - && e.response.status < 500) + const classification = classify_response({error: e}); + const decision = should_retry(classification, attempt, + base_max_retries, backoff_opts); + if (!decision.retry) { + // Give up. Emit one concise summary line per request (not one per + // attempt: under issue #104's burst of 50-100 calls x up to 3 + // retries, per-attempt stderr logging floods the transport) and + // only when we actually spent at least one retry, so a first-try + // non-retryable failure (e.g. a 4xx) stays silent. + if (retries) + console.error(`[base_request] gave up after ${retries} retr` + +`${retries==1 ? 'y' : 'ies'} (${total_delay_ms}ms ` + +`backoff total, last: ${classification.reason})`); throw e; } + retries++; + total_delay_ms += decision.delay_ms; + await sleep(decision.delay_ms); } } + // Unreachable: base_max_retries is clamped to [0,3], so the loop always runs + // at least once and either returns on success or throws on give-up. Kept as a + // final safeguard rather than falling off the end with an implicit undefined. throw last_err; } diff --git a/test/retry-utils.test.js b/test/retry-utils.test.js new file mode 100644 index 0000000..f2ad52d --- /dev/null +++ b/test/retry-utils.test.js @@ -0,0 +1,397 @@ +'use strict'; /*jslint node:true es9:true*/ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + OUTCOME, + classify_response, + compute_backoff, + parse_retry_after, + should_retry, +} from '../retry_utils.js'; + +// Fixed clock so HTTP-date Retry-After cases are deterministic. +const NOW = Date.parse('2026-01-19T20:51:08Z'); + +const classify_cases = [ + { + name: '200 is success, not retryable', + input: {status: 200, headers: {}}, + outcome: OUTCOME.SUCCESS, + retryable: false, + retry_after_ms: null, + }, + { + name: '204 is success', + input: {status: 204, headers: {}}, + outcome: OUTCOME.SUCCESS, + retryable: false, + }, + { + name: '502 from gateway is retryable (issue #104)', + input: {error: {response: {status: 502, headers: {}}}}, + outcome: OUTCOME.RETRYABLE, + retryable: true, + }, + { + name: '504 gateway timeout is retryable (issue #104)', + input: {error: {response: {status: 504, headers: {}}}}, + outcome: OUTCOME.RETRYABLE, + retryable: true, + }, + { + name: '500 is retryable', + input: {error: {response: {status: 500, headers: {}}}}, + outcome: OUTCOME.RETRYABLE, + retryable: true, + }, + { + name: '503 is retryable', + input: {error: {response: {status: 503, headers: {}}}}, + outcome: OUTCOME.RETRYABLE, + retryable: true, + }, + { + name: '408 request timeout is retryable', + input: {error: {response: {status: 408, headers: {}}}}, + outcome: OUTCOME.RETRYABLE, + retryable: true, + }, + { + name: 'unenumerated 5xx (599) falls back to retryable', + input: {error: {response: {status: 599, headers: {}}}}, + outcome: OUTCOME.RETRYABLE, + retryable: true, + }, + { + name: '301 is a redirect, not fatal (self-consistent outcome)', + input: {status: 301, headers: {location: 'https://shop.example/x'}}, + outcome: OUTCOME.REDIRECT, + retryable: false, + retry_after_ms: null, + }, + { + name: '302 is a redirect, never retried', + input: {error: {response: {status: 302, headers: {}}}}, + outcome: OUTCOME.REDIRECT, + retryable: false, + }, + { + name: '307 temporary redirect is a redirect outcome', + input: {status: 307, headers: {}}, + outcome: OUTCOME.REDIRECT, + retryable: false, + }, + { + name: '429 is rate_limited and retryable', + input: {error: {response: {status: 429, headers: {}}}}, + outcome: OUTCOME.RATE_LIMITED, + retryable: true, + }, + { + name: '429 surfaces numeric Retry-After in ms', + input: {error: {response: {status: 429, + headers: {'retry-after': '2'}}}}, + outcome: OUTCOME.RATE_LIMITED, + retryable: true, + retry_after_ms: 2000, + }, + { + name: '429 surfaces uppercase Retry-After header', + input: {error: {response: {status: 429, + headers: {'Retry-After': '5'}}}}, + outcome: OUTCOME.RATE_LIMITED, + retryable: true, + retry_after_ms: 5000, + }, + { + name: '503 surfaces HTTP-date Retry-After relative to now', + input: {error: {response: {status: 503, + headers: {'retry-after': 'Mon, 19 Jan 2026 20:51:18 GMT'}}}}, + outcome: OUTCOME.RETRYABLE, + retryable: true, + retry_after_ms: 10000, + }, + { + name: '403 is a first-class BLOCKED outcome, not a discarded error', + input: {error: {response: {status: 403, headers: {}}}}, + outcome: OUTCOME.BLOCKED, + retryable: false, + }, + { + name: '451 (unavailable for legal reasons) is BLOCKED', + input: {error: {response: {status: 451, headers: {}}}}, + outcome: OUTCOME.BLOCKED, + retryable: false, + }, + { + name: '400 is a terminal client error', + input: {error: {response: {status: 400, headers: {}}}}, + outcome: OUTCOME.CLIENT_ERROR, + retryable: false, + }, + { + name: '401 is a terminal client error', + input: {error: {response: {status: 401, headers: {}}}}, + outcome: OUTCOME.CLIENT_ERROR, + retryable: false, + }, + { + name: '404 is a terminal client error', + input: {error: {response: {status: 404, headers: {}}}}, + outcome: OUTCOME.CLIENT_ERROR, + retryable: false, + }, + { + name: 'ECONNRESET network error is retryable', + input: {error: {code: 'ECONNRESET'}}, + outcome: OUTCOME.RETRYABLE, + retryable: true, + }, + { + name: 'ETIMEDOUT network error is retryable', + input: {error: {code: 'ETIMEDOUT'}}, + outcome: OUTCOME.RETRYABLE, + retryable: true, + }, + { + name: 'undici connect timeout is retryable', + input: {error: {code: 'UND_ERR_CONNECT_TIMEOUT'}}, + outcome: OUTCOME.RETRYABLE, + retryable: true, + }, + { + name: 'unknown network code is fatal, never silently retried', + input: {error: {code: 'ESOMETHINGWEIRD'}}, + outcome: OUTCOME.FATAL, + retryable: false, + }, + { + name: 'no status and no code is fatal', + input: {error: {}}, + outcome: OUTCOME.FATAL, + retryable: false, + }, + { + name: 'non-object input is fatal, not a crash', + input: null, + outcome: OUTCOME.FATAL, + retryable: false, + }, +]; + +test('classify_response taxonomy (table-driven)', ()=>{ + for (const tc of classify_cases) + { + const got = classify_response(tc.input, NOW); + assert.equal(got.outcome, tc.outcome, + `${tc.name}: outcome ${got.outcome} != ${tc.outcome}`); + assert.equal(got.retryable, tc.retryable, + `${tc.name}: retryable ${got.retryable} != ${tc.retryable}`); + if (tc.retry_after_ms!==undefined) + { + assert.equal(got.retry_after_ms, tc.retry_after_ms, + `${tc.name}: retry_after_ms ${got.retry_after_ms} ` + +`!= ${tc.retry_after_ms}`); + } + assert.equal(typeof got.reason, 'string', + `${tc.name}: reason should be a string`); + } +}); + +const parse_retry_after_cases = [ + {name: 'undefined -> null', value: undefined, expected: null}, + {name: 'null -> null', value: null, expected: null}, + {name: 'empty string -> null', value: ' ', expected: null}, + {name: 'integer seconds -> ms', value: '3', expected: 3000}, + {name: 'large integer seconds -> ms', value: '120', expected: 120000}, + {name: 'zero seconds -> 0', value: '0', expected: 0}, + {name: 'garbage -> null', value: 'soon', expected: null}, + // Strictness: fractional/negative/number-like junk must be null (fall back to + // computed backoff), NOT 0 (an immediate retry via permissive Date.parse). + {name: 'fractional seconds -> null (not 0)', value: '1.5', expected: null}, + {name: 'negative seconds -> null (not 0)', value: '-3', expected: null}, + {name: 'leading-plus -> null', value: '+5', expected: null}, + {name: 'trailing junk -> null', value: '5s', expected: null}, + {name: 'numeric-with-space -> null', value: '5 ', expected: 5000}, + {name: 'date-shaped junk -> null', value: 'Mon, not a date', expected: null}, + { + name: 'future HTTP-date -> positive ms', + value: 'Mon, 19 Jan 2026 20:51:18 GMT', + expected: 10000, + }, + { + name: 'past HTTP-date clamps to 0', + value: 'Mon, 19 Jan 2026 20:51:00 GMT', + expected: 0, + }, +]; + +test('parse_retry_after (table-driven)', ()=>{ + for (const tc of parse_retry_after_cases) + { + const got = parse_retry_after(tc.value, NOW); + assert.equal(got, tc.expected, + `${tc.name}: got ${got} expected ${tc.expected}`); + } +}); + +const backoff_cases = [ + { + name: 'no jitter, attempt 0 -> base', + attempt: 0, + opts: {base_ms: 500, jitter: 'none'}, + expected: 500, + }, + { + name: 'no jitter, attempt 1 -> base*factor', + attempt: 1, + opts: {base_ms: 500, factor: 2, jitter: 'none'}, + expected: 1000, + }, + { + name: 'no jitter, attempt 3 -> base*factor^3', + attempt: 3, + opts: {base_ms: 500, factor: 2, jitter: 'none'}, + expected: 4000, + }, + { + name: 'no jitter caps at max_ms', + attempt: 10, + opts: {base_ms: 500, factor: 2, max_ms: 30000, jitter: 'none'}, + expected: 30000, + }, + { + name: 'retry_after_ms overrides exponential', + attempt: 5, + opts: {base_ms: 500, retry_after_ms: 2000, jitter: 'none'}, + expected: 2000, + }, + { + name: 'retry_after_ms is clamped to max_ms', + attempt: 0, + opts: {retry_after_ms: 120000, max_ms: 30000, jitter: 'none'}, + expected: 30000, + }, + { + name: 'full jitter with rng=0 -> 0', + attempt: 2, + opts: {base_ms: 500, jitter: 'full'}, + rng: ()=>0, + expected: 0, + }, + { + name: 'full jitter with rng=1 -> capped value', + attempt: 2, + opts: {base_ms: 500, factor: 2, jitter: 'full'}, + rng: ()=>1, + expected: 2000, + }, + { + name: 'equal jitter with rng=0 -> half capped', + attempt: 2, + opts: {base_ms: 500, factor: 2, jitter: 'equal'}, + rng: ()=>0, + expected: 1000, + }, + { + name: 'equal jitter with rng=1 -> full capped', + attempt: 2, + opts: {base_ms: 500, factor: 2, jitter: 'equal'}, + rng: ()=>1, + expected: 2000, + }, + { + name: 'negative attempt is floored to 0', + attempt: -3, + opts: {base_ms: 500, jitter: 'none'}, + expected: 500, + }, + { + name: 'defaults applied when opts empty', + attempt: 0, + opts: {jitter: 'none'}, + expected: 500, + }, +]; + +test('compute_backoff (table-driven)', ()=>{ + for (const tc of backoff_cases) + { + const rng = tc.rng || (()=>0.5); + const got = compute_backoff(tc.attempt, tc.opts, rng); + assert.equal(got, tc.expected, + `${tc.name}: got ${got} expected ${tc.expected}`); + } +}); + +test('full jitter stays within [0, capped] across the rng range', ()=>{ + const opts = {base_ms: 500, factor: 2, max_ms: 30000, jitter: 'full'}; + for (const r of [0, 0.01, 0.25, 0.5, 0.75, 0.99, 1]) + { + const delay = compute_backoff(3, opts, ()=>r); + assert.ok(delay>=0, `delay ${delay} should be >= 0`); + assert.ok(delay<=4000, `delay ${delay} should be <= capped 4000`); + } +}); + +const should_retry_cases = [ + { + name: 'retryable within budget -> retry', + classification: {retryable: true, retry_after_ms: null}, + attempt: 0, + max_retries: 3, + expect_retry: true, + }, + { + name: 'retryable but budget exhausted -> stop', + classification: {retryable: true, retry_after_ms: null}, + attempt: 3, + max_retries: 3, + expect_retry: false, + }, + { + name: 'non-retryable -> never retry', + classification: {retryable: false, retry_after_ms: null}, + attempt: 0, + max_retries: 3, + expect_retry: false, + }, + { + name: 'redirect classification -> never retried (no 3xx loop)', + classification: classify_response({status: 302, headers: {}}), + attempt: 0, + max_retries: 3, + expect_retry: false, + }, + { + name: 'missing classification -> never retry', + classification: null, + attempt: 0, + max_retries: 3, + expect_retry: false, + }, + { + name: 'retry honors classification retry_after_ms', + classification: {retryable: true, retry_after_ms: 2000}, + attempt: 0, + max_retries: 3, + opts: {jitter: 'none', max_ms: 30000}, + expect_retry: true, + expect_delay: 2000, + }, +]; + +test('should_retry budget + delay (table-driven)', ()=>{ + for (const tc of should_retry_cases) + { + const got = should_retry(tc.classification, tc.attempt, tc.max_retries, + tc.opts || {jitter: 'none'}, ()=>0.5); + assert.equal(got.retry, tc.expect_retry, + `${tc.name}: retry ${got.retry} != ${tc.expect_retry}`); + if (tc.expect_delay!==undefined) + { + assert.equal(got.delay_ms, tc.expect_delay, + `${tc.name}: delay ${got.delay_ms} != ${tc.expect_delay}`); + } + } +});