Transport-agnostic request queue with steady-state pacing, a hard sliding-window rate budget, a shared rate-limit cooldown breaker, linear retry backoff, and a per-attempt watchdog. @require-able from other userscripts.
此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.greasyfork.org/scripts/589318/1890407/Request%20Queue%20%28backoff%29%20Library.js
A transport-agnostic request queue: one paced, single-flight pump in front of whatever HTTP call you already make.
RequestQueue.create) for @require; also module.exports under Nodeperform(job), so it works with GM_xmlhttpRequest, fetch, $.ajax, etc. Caching, parsing, and header handling live in perform, not in the librarysubmit() is forwarded to perform() untouched, and its resolved value comes back out of the submit() promiseWhat the queue enforces, in the order a dispatch has to clear:
perform() rejection flagged .rateLimited pauses the whole queue, not just that job, until a cooldown elapses. The cooldown is max(err.retryAfter, cooldownBase * 2^streak) capped at cooldownMax, where streak is the run of consecutive rate-limit hits; a clean success resets it to zero.rateBudget dispatches per rolling rateWindow. When the window is full the queue defers without claiming a slot until the oldest in-window send ages out.ceil(rateWindow / rateBudget) between successive dispatches, so the budget is spread evenly across the window instead of being front-loaded and then stalling.Two knobs are derived, not configurable. rateBudget is floor(rateLimit * 0.8) — a fixed 20% headroom margin, so other consumers of the same endpoint's hard limit always have some of it free, and so clock jitter or an unrelated in-flight retry cannot push you over. The steady floor is then derived from rateBudget. At the defaults (rateLimit 60, rateWindow 60000) that is a budget of 48 and a floor of 1250ms: paced continuously it lands exactly 48 dispatches per 60s.
Other behaviours:
retryDelay * attempt, up to maxRetries attempts past the first, then the submit() promise rejects with the last error.perform() never settles, the queue is unstuck after timeout + watchdogGrace and the attempt is treated as failed (and so retried normally). Settlement is idempotent: a late perform() result arriving after the watchdog fired is ignored.job.priority puts the job at the head of the backlog.externalDelay, onDispatch, onRateLimit and onSuccess let several independent instances pool one budget and one cooldown through a store the caller owns. See Sharing a budget across instances.This is not a deduplicating cache. Concurrent submits are independent jobs even for the same URL; callers wanting single-flight coalescing or memoisation layer it above the queue (see Usage).
Note the distinction between the two failure paths. maxRetries counts ordinary failures and is charged to the job. A rate-limit hit is not the job's fault, so it re-queues the job with its retry count intact and charges a separate counter bounded by maxRateLimitRetries.
// @require https://update.greasyfork.org/...
// @grant GM_xmlhttpRequest
const queue = RequestQueue.create({
name: 'r34vi-tags',
rateWindow: 60000, // the API allows ~60 req per rolling 60s (NOT 60/s)
rateLimit: 60, // -> budget 48, floor 1250ms, both derived
debug: DEBUG,
perform: (job) => new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: job.method, url: job.url, timeout: 5000,
onload: xhr => {
if (xhr.status === 429) { // trip the shared cooldown breaker
const e = new Error('HTTP 429');
e.rateLimited = true;
e.retryAfter = parseRetryAfter(xhr.responseHeaders); // ms|null
return reject(e);
}
if (xhr.status !== 200) return reject(new Error('HTTP ' + xhr.status));
resolve(xhr);
},
onerror: () => reject(new Error('network')),
ontimeout: () => reject(new Error('timeout')),
});
}),
});
const xhr = await queue.submit({ method: 'GET', label: name, url });
Mapping HTTP 429 + Retry-After onto the flagged rejection is HTTP-specific, so it belongs in your perform:
// delta-seconds or HTTP-date -> ms, or null
function parseRetryAfter(headers) {
if (!headers) return null;
const m = /^retry-after:[ \t]*(.+?)[ \t]*$/im.exec(headers);
if (!m) return null;
if (/^\d+$/.test(m[1])) return parseInt(m[1], 10) * 1000;
const when = Date.parse(m[1]);
return Number.isNaN(when) ? null : Math.max(0, when - Date.now());
}
Caching and single-flight go above the queue, never inside it:
const known = new Map(); // name -> resolved value
const listeners = new Map(); // name -> [cb]
function classify(name, cb) {
if (known.has(name)) return cb(known.get(name));
if (listeners.has(name)) { listeners.get(name).push(cb); return; }
listeners.set(name, [cb]);
queue.submit({ method: 'GET', label: name, url: urlFor(name) })
.then(xhr => settle(name, parseTagType(xhr.responseText)))
.catch(() => settle(name, UNKNOWN));
}
function settle(name, value) {
known.set(name, value);
(listeners.get(name) || []).forEach(fn => fn(value));
listeners.delete(name);
}
Everything above is per-instance. A queue paces itself; it has no idea another queue is hitting the same endpoint. That is usually fine — but a userscript gets one instance per tab, and five open tabs mean five queues each politely observing a budget they are each blowing through five times over.
Four optional hooks close that gap without the library taking a position on where shared state lives. The queue asks how long to wait, and reports what it did; the caller owns the store (localStorage across tabs, an extension background page, a BroadcastChannel, whatever fits).
| hook | signature | when |
|---|---|---|
externalDelay |
() => ms |
every pump tick, as a third dispatch gate. Return >0 to defer that long and be re-asked; 0 to proceed |
onDispatch |
(at) => void |
an attempt is committed; at is the epoch-ms stamp recorded in this instance's window |
onRateLimit |
(until, wait, streak) => void |
the cooldown breaker opened |
onSuccess |
() => void |
a clean success — the same event that resets this instance's streak |
The important property is where externalDelay is consulted: alongside the cooldown breaker and the sliding window, in the defer-without-claiming-a-slot phase, before the attempt is committed. So a wait returned there does not occupy the single-flight slot, and the watchdog is not yet armed — returning a sixteen-minute pooled cooldown is safe. Sleeping for the same span inside perform() would instead be shot by the watchdog at timeout + watchdogGrace and reported as a hung transport.
const queue = RequestQueue.create({
name: 'mail-poll',
rateWindow: 60000,
rateLimit: 38, // -> budget 30, floor 2000ms, both derived
perform: doRequest,
// gate on whatever the pool says, on top of this instance's own pacing
externalDelay: () => Math.max(pool.cooldownRemaining(), pool.budgetWait()),
// ...and keep the pool current
onDispatch: at => pool.recordSend(at),
onRateLimit: (until) => pool.openCooldown(until),
onSuccess: () => pool.clearStreak(),
});
Two cautions. externalDelay runs on every pump tick, including its own reschedules, so keep it cheap — memoise anything that scans storage behind a change event rather than re-enumerating per call. And all four hooks run inside the pump: a throw is caught and logged (externalDelay then counts as "no wait") rather than being allowed to strand a dispatch, but a hook that throws every time silently disables the pooling it was added for.
clear() resets only the calling instance. Anything published through the hooks belongs to the caller's store and keeps gating both peers and, via externalDelay, this queue.
RequestQueue.create(cfg):
| key | default | note |
|---|---|---|
| perform | required | async (job) => result. Resolve with the value to deliver to submit(). Reject to fail the attempt; reject with truthy .rateLimited (optionally .retryAfter in ms) to trip the shared cooldown instead |
| rateWindow | 60000 | sliding-window span for the hard backstop |
| rateLimit | 60 | the hard req/window cap the remote enforces; rateBudget and the steady floor are derived from it |
| maxRetries | 3 | attempts past the first before rejecting the submit() promise |
| retryDelay | 2000 | linear backoff unit; wait is retryDelay * attempt |
| cooldownBase | 60000 | base rate-limit cooldown; escalates as base * 2^streak |
| cooldownMax | 960000 | ceiling for the escalated cooldown |
| maxRateLimitRetries | 5 | rate-limit re-queues for one job before giving up on it |
| timeout | 5000 | per-attempt budget the watchdog assumes; set it to match your transport's own timeout |
| watchdogGrace | 3000 | extra ms past timeout before deeming a call hung |
| name | 'request-queue' | log prefix |
| debug | falsy | when truthy, emits lifecycle logs via console.debug; warnings are always emitted |
| externalDelay | none | () => ms. Third dispatch gate for pooled state; >0 defers and is re-asked. See Sharing a budget across instances |
| onDispatch | none | (at) => void as an attempt is committed |
| onRateLimit | none | (until, wait, streak) => void when the cooldown breaker opens |
| onSuccess | none | () => void on a clean success |
timeout is advisory — the library does not abort your transport, it only decides when to stop waiting on it. Keep it at or above whatever timeout perform itself sets, or the watchdog will fire on requests that were going to succeed.
create() returns:
| member | note |
|---|---|
submit(job) |
Promise resolving with perform()'s value, rejecting on terminal failure. Truthy job.priority jumps the backlog. job.label (else job.url) names the job in logs |
size() |
count of not-yet-started jobs |
clear() |
drop the backlog and reset pacing/cooldown state. Promises for dropped jobs never settle; an attempt already in flight is left alone and still settles normally |
const RequestQueue = require('./request-queue-backoff.lib.user.js');
Under a userscript manager the library attaches RequestQueue to unsafeWindow (falling back to globalThis); under Node it also sets module.exports, which is how tests/request-queue-backoff.lib.test.js drives it with a stub transport over the real clock (node tests/request-queue-backoff.lib.test.js; exits 0 on all-pass).