@bobfrankston/mailx-imap 0.1.147 → 0.1.149
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +29 -0
- package/index.js +150 -1
- package/package.json +7 -7
- package/test/rate-limit-backoff.test.mjs +61 -0
package/index.d.ts
CHANGED
|
@@ -77,6 +77,18 @@ export declare class ImapManager extends EventEmitter {
|
|
|
77
77
|
* (rate limits, network) are NOT recorded here — they keep the normal
|
|
78
78
|
* 60s deadman cadence. */
|
|
79
79
|
private watchAuthFailed;
|
|
80
|
+
/** Accounts the SERVER has told us to back off, with when they may be
|
|
81
|
+
* tried again and how many consecutive refusals we've had.
|
|
82
|
+
*
|
|
83
|
+
* AOL/Yahoo answer an over-quota LOGIN with `[LIMIT] LOGIN Rate limit
|
|
84
|
+
* hit.` — an explicit instruction, not a transient blip. mailx used to
|
|
85
|
+
* treat it as transient and keep the 60 s deadman cadence, so every
|
|
86
|
+
* refusal was followed by another LOGIN a minute later: measured on
|
|
87
|
+
* 2026-08-09, four separate runs of 4–22 minutes, one failed attempt per
|
|
88
|
+
* minute throughout, every one of them spending quota that keeps the
|
|
89
|
+
* limiter hot. Backing off is not politeness, it is how the account
|
|
90
|
+
* becomes usable again sooner. */
|
|
91
|
+
private rateLimited;
|
|
80
92
|
private fetchClients;
|
|
81
93
|
/** The Store is the architectural nexus — owner of MailxDB +
|
|
82
94
|
* FileMessageStore + the event bus. This package (mailx-imap) is a
|
|
@@ -243,6 +255,23 @@ export declare class ImapManager extends EventEmitter {
|
|
|
243
255
|
* client; the slot is released when logout() or destroy() runs.
|
|
244
256
|
* `purpose` is a short tag printed alongside the `[conn+]` log so we can
|
|
245
257
|
* tell which code path (ops/idle/etc.) opened each connection. */
|
|
258
|
+
/** Remaining cooldown in ms for an account the server rate-limited, or 0. */
|
|
259
|
+
rateLimitRemaining(accountId: string): number;
|
|
260
|
+
/** Record a server rate-limit refusal and widen the cooldown. 5 → 10 → 20
|
|
261
|
+
* → 30 min (capped): long enough to actually clear AOL's window, short
|
|
262
|
+
* enough that a mistake costs one poll cycle rather than an afternoon. */
|
|
263
|
+
private noteRateLimited;
|
|
264
|
+
/** Clear the cooldown after a connection succeeds. */
|
|
265
|
+
private noteRateLimitCleared;
|
|
266
|
+
/**
|
|
267
|
+
* Fetch bodies for rows that carry IMAP uids on an account whose normal
|
|
268
|
+
* body path is a REST API (Gmail today). Uses the account's IMAP
|
|
269
|
+
* connection — the same one the server search used to find them.
|
|
270
|
+
*
|
|
271
|
+
* Returns how many bodies landed. Failures are logged per row and do not
|
|
272
|
+
* abort the rest: one unreadable message must not stall the queue.
|
|
273
|
+
*/
|
|
274
|
+
private fetchBodiesOverImap;
|
|
246
275
|
private newClient;
|
|
247
276
|
/** Force-close every IMAP socket for an account — both lane clients
|
|
248
277
|
* (ops + fast) plus any lingering ones in openClients (e.g. an IDLE
|
package/index.js
CHANGED
|
@@ -27,6 +27,21 @@ const OUTBOX_RETRY_DELAY_MS = 60000;
|
|
|
27
27
|
* after boot always runs one (the map is empty); thereafter it's throttled to
|
|
28
28
|
* this interval so the QRESYNC speedup is preserved in steady state. */
|
|
29
29
|
const RECONCILE_THROTTLE_MS = 15 * 60 * 1000;
|
|
30
|
+
/**
|
|
31
|
+
* An IMAP UID is a 32-bit unsigned integer (RFC 3501 §2.3.1.1). The API
|
|
32
|
+
* providers mint their uid from the provider's own message id — Gmail uses
|
|
33
|
+
* `parseInt(id.slice(-12), 16)`, a 48-bit value — so the two uid spaces are
|
|
34
|
+
* disjoint and this bound tells them apart exactly, not heuristically.
|
|
35
|
+
*
|
|
36
|
+
* It matters because ONE ACCOUNT can hold rows from both: a Gmail account
|
|
37
|
+
* syncs bodies through the API, but a server search runs over IMAP and
|
|
38
|
+
* stores rows with IMAP UIDs. Those rows are unfetchable by the API path —
|
|
39
|
+
* it maps uid→id by listing the label and hashing each id, and an IMAP uid
|
|
40
|
+
* is not in that map at any depth. The body request simply returned nothing,
|
|
41
|
+
* forever, with the viewer counting seconds (Bob 2026-08-09: "why the
|
|
42
|
+
* forever fetch?" / "why did gmail search find them if they can't be read?").
|
|
43
|
+
*/
|
|
44
|
+
const IMAP_UID_MAX = 0xFFFFFFFF;
|
|
30
45
|
/** Parse X-Mailx-Retry* tracking headers from a raw RFC822 message. */
|
|
31
46
|
function parseRetryInfo(raw) {
|
|
32
47
|
const headerEnd = raw.search(/\r?\n\r?\n/);
|
|
@@ -219,6 +234,18 @@ export class ImapManager extends EventEmitter {
|
|
|
219
234
|
* (rate limits, network) are NOT recorded here — they keep the normal
|
|
220
235
|
* 60s deadman cadence. */
|
|
221
236
|
watchAuthFailed = new Map();
|
|
237
|
+
/** Accounts the SERVER has told us to back off, with when they may be
|
|
238
|
+
* tried again and how many consecutive refusals we've had.
|
|
239
|
+
*
|
|
240
|
+
* AOL/Yahoo answer an over-quota LOGIN with `[LIMIT] LOGIN Rate limit
|
|
241
|
+
* hit.` — an explicit instruction, not a transient blip. mailx used to
|
|
242
|
+
* treat it as transient and keep the 60 s deadman cadence, so every
|
|
243
|
+
* refusal was followed by another LOGIN a minute later: measured on
|
|
244
|
+
* 2026-08-09, four separate runs of 4–22 minutes, one failed attempt per
|
|
245
|
+
* minute throughout, every one of them spending quota that keeps the
|
|
246
|
+
* limiter hot. Backing off is not politeness, it is how the account
|
|
247
|
+
* becomes usable again sooner. */
|
|
248
|
+
rateLimited = new Map();
|
|
222
249
|
fetchClients = new Map();
|
|
223
250
|
/** The Store is the architectural nexus — owner of MailxDB +
|
|
224
251
|
* FileMessageStore + the event bus. This package (mailx-imap) is a
|
|
@@ -277,7 +304,7 @@ export class ImapManager extends EventEmitter {
|
|
|
277
304
|
else if (/UNAVAILABLE|Maximum number of connections|too many connections/i.test(errMsg)) {
|
|
278
305
|
d.connCapHits++;
|
|
279
306
|
}
|
|
280
|
-
else if (/429|rate limit/i.test(errMsg)) {
|
|
307
|
+
else if (/429|rate limit|\[LIMIT\]/i.test(errMsg)) {
|
|
281
308
|
d.rateLimitWaits++;
|
|
282
309
|
}
|
|
283
310
|
else {
|
|
@@ -894,7 +921,101 @@ export class ImapManager extends EventEmitter {
|
|
|
894
921
|
* client; the slot is released when logout() or destroy() runs.
|
|
895
922
|
* `purpose` is a short tag printed alongside the `[conn+]` log so we can
|
|
896
923
|
* tell which code path (ops/idle/etc.) opened each connection. */
|
|
924
|
+
/** Remaining cooldown in ms for an account the server rate-limited, or 0. */
|
|
925
|
+
rateLimitRemaining(accountId) {
|
|
926
|
+
const e = this.rateLimited.get(accountId);
|
|
927
|
+
if (!e)
|
|
928
|
+
return 0;
|
|
929
|
+
const left = e.until - Date.now();
|
|
930
|
+
if (left <= 0) {
|
|
931
|
+
this.rateLimited.delete(accountId);
|
|
932
|
+
return 0;
|
|
933
|
+
}
|
|
934
|
+
return left;
|
|
935
|
+
}
|
|
936
|
+
/** Record a server rate-limit refusal and widen the cooldown. 5 → 10 → 20
|
|
937
|
+
* → 30 min (capped): long enough to actually clear AOL's window, short
|
|
938
|
+
* enough that a mistake costs one poll cycle rather than an afternoon. */
|
|
939
|
+
noteRateLimited(accountId, errMsg) {
|
|
940
|
+
const prev = this.rateLimited.get(accountId);
|
|
941
|
+
const strikes = (prev?.strikes || 0) + 1;
|
|
942
|
+
const mins = Math.min(30, 5 * Math.pow(2, strikes - 1));
|
|
943
|
+
const until = Date.now() + mins * 60_000;
|
|
944
|
+
this.rateLimited.set(accountId, { until, strikes });
|
|
945
|
+
console.warn(` [rate-limit] ${accountId}: server said "${errMsg.trim().slice(0, 60)}" — pausing ALL lanes for ${mins} min (strike ${strikes})`);
|
|
946
|
+
this.emit("rateLimited", { accountId, untilMs: until, minutes: mins, strikes });
|
|
947
|
+
}
|
|
948
|
+
/** Clear the cooldown after a connection succeeds. */
|
|
949
|
+
noteRateLimitCleared(accountId) {
|
|
950
|
+
if (this.rateLimited.delete(accountId)) {
|
|
951
|
+
console.log(` [rate-limit] ${accountId}: connection accepted again — cooldown cleared`);
|
|
952
|
+
this.emit("rateLimitCleared", { accountId });
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Fetch bodies for rows that carry IMAP uids on an account whose normal
|
|
957
|
+
* body path is a REST API (Gmail today). Uses the account's IMAP
|
|
958
|
+
* connection — the same one the server search used to find them.
|
|
959
|
+
*
|
|
960
|
+
* Returns how many bodies landed. Failures are logged per row and do not
|
|
961
|
+
* abort the rest: one unreadable message must not stall the queue.
|
|
962
|
+
*/
|
|
963
|
+
async fetchBodiesOverImap(accountId, rows) {
|
|
964
|
+
const folders = this.db.getFolders(accountId);
|
|
965
|
+
let fetched = 0;
|
|
966
|
+
let client = null;
|
|
967
|
+
try {
|
|
968
|
+
client = await this.newClient(accountId, "fetch");
|
|
969
|
+
await client.connect?.();
|
|
970
|
+
for (const row of rows) {
|
|
971
|
+
const folder = folders.find(f => f.id === row.folderId);
|
|
972
|
+
if (!folder)
|
|
973
|
+
continue;
|
|
974
|
+
try {
|
|
975
|
+
const msg = await client.fetchMessageByUid(folder.path, row.uid, { source: true });
|
|
976
|
+
const source = msg?.source;
|
|
977
|
+
if (!source) {
|
|
978
|
+
// Not on the server either — stop asking for it every
|
|
979
|
+
// sweep, but say so once rather than silently.
|
|
980
|
+
console.log(` [prefetch] ${accountId}/${folder.path} uid ${row.uid}: not on the server over IMAP either — marking empty`);
|
|
981
|
+
this.markPrefetchEmpty(accountId, row.folderId, row.uid);
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
const raw = Buffer.from(source, "utf-8");
|
|
985
|
+
const bodyPath = await this.bodyStore.putMessage(accountId, row.folderId, row.uid, raw);
|
|
986
|
+
const parsed = await extractPreview(source);
|
|
987
|
+
this.db.updateBodyMeta(accountId, row.folderId, row.uid, bodyPath, parsed.hasAttachments, parsed.preview);
|
|
988
|
+
this.emit("bodyCached", accountId, row.uid);
|
|
989
|
+
fetched++;
|
|
990
|
+
}
|
|
991
|
+
catch (e) {
|
|
992
|
+
console.error(` [prefetch] ${accountId}/${folder.path} uid ${row.uid}: IMAP body fetch failed: ${e?.message || e}`);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
catch (e) {
|
|
997
|
+
console.error(` [prefetch] ${accountId}: IMAP fallback could not open a connection: ${e?.message || e}`);
|
|
998
|
+
}
|
|
999
|
+
finally {
|
|
1000
|
+
try {
|
|
1001
|
+
await client?.logout?.();
|
|
1002
|
+
}
|
|
1003
|
+
catch { /* socket already gone */ }
|
|
1004
|
+
}
|
|
1005
|
+
if (fetched > 0)
|
|
1006
|
+
console.log(` [prefetch] ${accountId}: ${fetched} body(ies) fetched over IMAP for API-account rows`);
|
|
1007
|
+
return fetched;
|
|
1008
|
+
}
|
|
897
1009
|
async newClient(accountId, purpose = "?") {
|
|
1010
|
+
// Refuse to spend a LOGIN the server has already told us it will not
|
|
1011
|
+
// accept. Every lane funnels through newClient, so one check covers
|
|
1012
|
+
// sync, quickCheck, IDLE, outbox and fetch alike — which matters,
|
|
1013
|
+
// because the lanes retry independently and would otherwise take turns
|
|
1014
|
+
// poking the limiter.
|
|
1015
|
+
const cooldown = this.rateLimitRemaining(accountId);
|
|
1016
|
+
if (cooldown > 0) {
|
|
1017
|
+
throw new Error(`rate-limited by server — retrying in ${Math.ceil(cooldown / 60_000)} min (no LOGIN attempted)`);
|
|
1018
|
+
}
|
|
898
1019
|
if (this.reauthenticating.has(accountId))
|
|
899
1020
|
throw new Error(`Account ${accountId} is re-authenticating`);
|
|
900
1021
|
const config = this.configs.get(accountId);
|
|
@@ -985,11 +1106,22 @@ export class ImapManager extends EventEmitter {
|
|
|
985
1106
|
const sock = client?.native?.transport?.socket;
|
|
986
1107
|
if (typeof sock?.once === "function")
|
|
987
1108
|
sock.once("close", () => markClosed("socket-close"));
|
|
1109
|
+
// A LOGIN the server accepted is the only proof the
|
|
1110
|
+
// cooldown is over — clear it here rather than letting
|
|
1111
|
+
// it lapse on a timer alone.
|
|
1112
|
+
this.noteRateLimitCleared(accountId);
|
|
988
1113
|
return r;
|
|
989
1114
|
}
|
|
990
1115
|
catch (e) {
|
|
991
1116
|
emitPhase("failed", e?.message || String(e));
|
|
992
1117
|
markClosed("connect-failed");
|
|
1118
|
+
// Every lane (sync, quickCheck, IDLE, outbox, fetch)
|
|
1119
|
+
// connects through here, so this one line is what makes
|
|
1120
|
+
// a rate-limit refusal stop ALL of them instead of each
|
|
1121
|
+
// discovering it separately, once a minute, forever.
|
|
1122
|
+
const em = e?.message || String(e);
|
|
1123
|
+
if (/[LIMIT]|rate limit/i.test(em))
|
|
1124
|
+
this.noteRateLimited(accountId, em);
|
|
993
1125
|
throw e;
|
|
994
1126
|
}
|
|
995
1127
|
};
|
|
@@ -3801,7 +3933,16 @@ export class ImapManager extends EventEmitter {
|
|
|
3801
3933
|
// message in multiple labels gets fetched twice under current
|
|
3802
3934
|
// grouping. A deeper label-native redesign is a separate TODO.
|
|
3803
3935
|
const byFolder = new Map();
|
|
3936
|
+
// Rows whose uid is in the IMAP space cannot be addressed by
|
|
3937
|
+
// the API provider at all. Route them to the IMAP fetcher —
|
|
3938
|
+
// the same transport that created them — instead of handing
|
|
3939
|
+
// the API a name it cannot resolve.
|
|
3940
|
+
const imapSpaceRows = [];
|
|
3804
3941
|
for (const m of missing) {
|
|
3942
|
+
if (m.uid <= IMAP_UID_MAX) {
|
|
3943
|
+
imapSpaceRows.push({ folderId: m.folderId, uid: m.uid });
|
|
3944
|
+
continue;
|
|
3945
|
+
}
|
|
3805
3946
|
let arr = byFolder.get(m.folderId);
|
|
3806
3947
|
if (!arr) {
|
|
3807
3948
|
arr = [];
|
|
@@ -3809,6 +3950,14 @@ export class ImapManager extends EventEmitter {
|
|
|
3809
3950
|
}
|
|
3810
3951
|
arr.push(m.uid);
|
|
3811
3952
|
}
|
|
3953
|
+
if (imapSpaceRows.length > 0) {
|
|
3954
|
+
console.log(` [prefetch] ${accountId}: ${imapSpaceRows.length} row(s) carry IMAP uids on an API account (server-search results) — fetching those over IMAP`);
|
|
3955
|
+
const fetchedOverImap = await this.fetchBodiesOverImap(accountId, imapSpaceRows);
|
|
3956
|
+
if (fetchedOverImap > 0) {
|
|
3957
|
+
madeProgress = true;
|
|
3958
|
+
counters.totalFetched += fetchedOverImap;
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3812
3961
|
const folders = this.db.getFolders(accountId);
|
|
3813
3962
|
const api = this.getApiProvider(accountId);
|
|
3814
3963
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-imap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.149",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
},
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@bobfrankston/mailx-types": "^0.1.
|
|
13
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
14
|
-
"@bobfrankston/mailx-store": "^0.1.
|
|
12
|
+
"@bobfrankston/mailx-types": "^0.1.42",
|
|
13
|
+
"@bobfrankston/mailx-settings": "^0.1.51",
|
|
14
|
+
"@bobfrankston/mailx-store": "^0.1.83",
|
|
15
15
|
"@bobfrankston/iflow-direct": "^0.1.65",
|
|
16
16
|
"@bobfrankston/tcp-transport": "^0.1.8",
|
|
17
17
|
"@bobfrankston/smtp-direct": "^0.1.9",
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
},
|
|
38
38
|
".transformedSnapshot": {
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@bobfrankston/mailx-types": "^0.1.
|
|
41
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
42
|
-
"@bobfrankston/mailx-store": "^0.1.
|
|
40
|
+
"@bobfrankston/mailx-types": "^0.1.42",
|
|
41
|
+
"@bobfrankston/mailx-settings": "^0.1.51",
|
|
42
|
+
"@bobfrankston/mailx-store": "^0.1.83",
|
|
43
43
|
"@bobfrankston/iflow-direct": "^0.1.65",
|
|
44
44
|
"@bobfrankston/tcp-transport": "^0.1.8",
|
|
45
45
|
"@bobfrankston/smtp-direct": "^0.1.9",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AOL/Yahoo answer an over-quota LOGIN with `[LIMIT] LOGIN Rate limit hit.`
|
|
3
|
+
* Before v1.2.240 every lane treated that as transient and retried on its own
|
|
4
|
+
* 60 s cadence, so a refusal was followed by another LOGIN a minute later for
|
|
5
|
+
* as long as it lasted (measured 2026-08-09: four runs, up to 22 minutes,
|
|
6
|
+
* ~one wasted LOGIN per minute).
|
|
7
|
+
*
|
|
8
|
+
* These checks drive the real ImapManager methods over the cooldown state.
|
|
9
|
+
* Run: node packages/mailx-imap/test/rate-limit-backoff.test.mjs
|
|
10
|
+
*/
|
|
11
|
+
import assert from "node:assert";
|
|
12
|
+
|
|
13
|
+
const { ImapManager } = await import("file:///Y:/dev/email/mailx/app/packages/mailx-imap/index.js");
|
|
14
|
+
|
|
15
|
+
const mgr = Object.create(ImapManager.prototype);
|
|
16
|
+
mgr.rateLimited = new Map();
|
|
17
|
+
const events = [];
|
|
18
|
+
mgr.emit = (name, payload) => { events.push({ name, payload }); };
|
|
19
|
+
|
|
20
|
+
const LIMIT = "Login failed: [LIMIT] LOGIN Rate limit hit.";
|
|
21
|
+
|
|
22
|
+
// 1. A refusal starts a cooldown, and it is reported (not swallowed).
|
|
23
|
+
assert.strictEqual(mgr.rateLimitRemaining("aol"), 0, "clean account has no cooldown");
|
|
24
|
+
mgr.noteRateLimited("aol", LIMIT);
|
|
25
|
+
const first = mgr.rateLimitRemaining("aol");
|
|
26
|
+
assert.ok(first > 4 * 60_000 && first <= 5 * 60_000, `first cooldown should be ~5 min, got ${Math.round(first / 1000)}s`);
|
|
27
|
+
assert.ok(events.some(e => e.name === "rateLimited" && e.payload.accountId === "aol"),
|
|
28
|
+
"a cooldown must be announced — a silently paused account looks broken");
|
|
29
|
+
|
|
30
|
+
// 2. Repeated refusals widen it: 5 → 10 → 20 → 30 (capped).
|
|
31
|
+
const mins = [];
|
|
32
|
+
for (let i = 0; i < 4; i++) { mgr.noteRateLimited("aol", LIMIT); mins.push(Math.round(mgr.rateLimitRemaining("aol") / 60_000)); }
|
|
33
|
+
assert.deepStrictEqual(mins, [10, 20, 30, 30], `backoff should widen and cap at 30, got ${mins.join(",")}`);
|
|
34
|
+
|
|
35
|
+
// 3. Other accounts are untouched — the limit is per-account, not global.
|
|
36
|
+
assert.strictEqual(mgr.rateLimitRemaining("bobma"), 0, "one account's cooldown must not pause another");
|
|
37
|
+
|
|
38
|
+
// 4. A successful connection clears it, and says so.
|
|
39
|
+
mgr.noteRateLimitCleared("aol");
|
|
40
|
+
assert.strictEqual(mgr.rateLimitRemaining("aol"), 0, "success must clear the cooldown");
|
|
41
|
+
assert.ok(events.some(e => e.name === "rateLimitCleared"), "recovery must be announced too");
|
|
42
|
+
|
|
43
|
+
// 5. An expired cooldown lapses on its own (no success needed).
|
|
44
|
+
mgr.rateLimited.set("aol", { until: Date.now() - 1, strikes: 3 });
|
|
45
|
+
assert.strictEqual(mgr.rateLimitRemaining("aol"), 0, "a lapsed cooldown must not block forever");
|
|
46
|
+
assert.ok(!mgr.rateLimited.has("aol"), "lapsed entry should be dropped, not left to accumulate");
|
|
47
|
+
|
|
48
|
+
// 6. THE POINT: while cooling down, newClient must refuse to spend a LOGIN —
|
|
49
|
+
// for every lane, since each one used to discover the limit separately.
|
|
50
|
+
mgr.rateLimited.set("aol", { until: Date.now() + 5 * 60_000, strikes: 1 });
|
|
51
|
+
let attempted = 0;
|
|
52
|
+
mgr.acquireHostSlot = async () => { attempted++; return () => {}; }; // reached only if the guard lets it through
|
|
53
|
+
for (const lane of ["sync", "quickCheck", "idle", "outbox", "fetch"]) {
|
|
54
|
+
await assert.rejects(
|
|
55
|
+
() => ImapManager.prototype.newClient.call(mgr, "aol", lane),
|
|
56
|
+
/rate-limited by server/,
|
|
57
|
+
`lane "${lane}" must be refused while the account is cooling down`);
|
|
58
|
+
}
|
|
59
|
+
assert.strictEqual(attempted, 0, "no lane may reach the connect path during a cooldown");
|
|
60
|
+
|
|
61
|
+
console.log("rate-limit backoff: 6 scenarios passed");
|