Request Queue (backoff) Library

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.

此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.greasyfork.org/scripts/589318/1890407/Request%20Queue%20%28backoff%29%20Library.js

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

作者
SassyRhombus
版本
1.1.1
创建于
2026-07-31
更新于
2026-07-31
大小
25.6 KB
许可证
MIT-0

Overview

A transport-agnostic request queue: one paced, single-flight pump in front of whatever HTTP call you already make.

  • exposed as a global (RequestQueue.create) for @require; also module.exports under Node
  • the transport is caller-supplied as an async perform(job), so it works with GM_xmlhttpRequest, fetch, $.ajax, etc. Caching, parsing, and header handling live in perform, not in the library
  • jobs are opaque: whatever you hand submit() is forwarded to perform() untouched, and its resolved value comes back out of the submit() promise

What the queue enforces, in the order a dispatch has to clear:

  1. Cooldown breaker. A 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.
  2. Hard sliding window. Never more than 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.
  3. Steady-state pacing. A constant inter-request floor of 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:

  • Coalesced single-chain pacing. Every pump (re)start — external submits, the post-request resume, cooldown and window-full deferrals — funnels through one shared timer. The single-flight guard alone only stops two requests being outstanding at the same instant; it does not stop N independent paced loops interleaving (submit a job in the gap between a settle and its scheduled resume and each would spawn its own self-sustaining chain, multiplying the effective rate). Coalescing is what prevents the second chain forming.
  • Linear retry backoff for ordinary failures: retryDelay * attempt, up to maxRetries attempts past the first, then the submit() promise rejects with the last error.
  • Per-attempt watchdog. If 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.
  • Priority jobs. A truthy job.priority puts the job at the head of the backlog.
  • Optional shared-budget hooks. 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.

Usage

// @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);
}

Sharing a budget across instances

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.

Options

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.

API

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

Module exports

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).