feat: add automatic account cooldown recovery
Replace session-long account failure pauses with classified 15, 30, 60, and 120 minute cooldowns for temporary account problems. Keep credential, OTP, banned, and disabled states manual while ignoring unknown, file, network, hoster, and bare WAF errors. Reset escalation after confirmed uploads, deduplicate parallel failures, publish revisioned pause snapshots, and show a stable localized countdown with automatic reactivation.
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { createAccountPicker, enabledAccountsFor } = require('../lib/account-rotation');
|
||||
const {
|
||||
classifyAccountFailure,
|
||||
createAccountCooldownController,
|
||||
createAccountPicker,
|
||||
enabledAccountsFor
|
||||
} = require('../lib/account-rotation');
|
||||
|
||||
const hasCreds = (hoster, a) => !!(a && a.creds !== false);
|
||||
function acc(id, opts = {}) { return { id, enabled: opts.enabled, creds: opts.creds }; }
|
||||
@@ -124,3 +129,134 @@ test('persisted cursor wraps correctly after the enabled-account count shrinks',
|
||||
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds, indices: { 'byse.sx': 7 } });
|
||||
assert.deepStrictEqual(picks(pick, 'byse.sx', 3), ['a2', 'a1', 'a2']);
|
||||
});
|
||||
|
||||
test('temporary account failures escalate through 15, 30, 60, and 120 minute cooldowns', () => {
|
||||
let now = 1_000_000;
|
||||
const scheduled = [];
|
||||
const controller = createAccountCooldownController({
|
||||
now: () => now,
|
||||
setTimer: (callback, delay) => { scheduled.push({ callback, delay }); return scheduled.length; },
|
||||
clearTimer: () => {}
|
||||
});
|
||||
|
||||
const expectedMinutes = [15, 30, 60, 120, 120];
|
||||
for (let index = 0; index < expectedMinutes.length; index++) {
|
||||
const record = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
|
||||
assert.equal(record.failures, index + 1);
|
||||
assert.equal(record.pausedUntil, now + expectedMinutes[index] * 60_000);
|
||||
assert.equal(scheduled.at(-1).delay, expectedMinutes[index] * 60_000);
|
||||
now = record.pausedUntil;
|
||||
assert.deepEqual(controller.releaseExpired(), [`byse.sx:acc-1`]);
|
||||
}
|
||||
});
|
||||
|
||||
test('parallel duplicate failures count as one strike until the active cooldown expires', () => {
|
||||
let now = 50_000;
|
||||
const controller = createAccountCooldownController({
|
||||
now: () => now,
|
||||
setTimer: () => 1,
|
||||
clearTimer: () => {}
|
||||
});
|
||||
const first = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
|
||||
const duplicate = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
|
||||
now = first.pausedUntil;
|
||||
controller.releaseExpired();
|
||||
const next = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
|
||||
|
||||
assert.equal(duplicate.failures, 1);
|
||||
assert.equal(duplicate.pausedUntil, first.pausedUntil);
|
||||
assert.equal(next.failures, 2);
|
||||
assert.equal(next.pausedUntil, now + 30 * 60_000);
|
||||
});
|
||||
|
||||
test('a successful upload resets cooldown escalation to the first level', () => {
|
||||
let now = 5_000;
|
||||
const controller = createAccountCooldownController({
|
||||
now: () => now,
|
||||
setTimer: () => 1,
|
||||
clearTimer: () => {}
|
||||
});
|
||||
const first = controller.markFailure({ hoster: 'voe.sx', accountId: 'acc-1', mode: 'cooldown' });
|
||||
now = first.pausedUntil;
|
||||
controller.releaseExpired();
|
||||
controller.markFailure({ hoster: 'voe.sx', accountId: 'acc-1', mode: 'cooldown' });
|
||||
|
||||
assert.equal(controller.markSuccess('voe.sx', 'acc-1'), true);
|
||||
const afterSuccess = controller.markFailure({ hoster: 'voe.sx', accountId: 'acc-1', mode: 'cooldown' });
|
||||
assert.equal(afterSuccess.failures, 1);
|
||||
assert.equal(afterSuccess.pausedUntil, now + 15 * 60_000);
|
||||
});
|
||||
|
||||
test('manual account pauses never expire and can be reset explicitly', () => {
|
||||
let now = 10_000;
|
||||
const controller = createAccountCooldownController({
|
||||
now: () => now,
|
||||
setTimer: () => { throw new Error('manual pauses must not schedule timers'); },
|
||||
clearTimer: () => {}
|
||||
});
|
||||
const record = controller.markFailure({ hoster: 'doodstream.com', accountId: 'acc-1', mode: 'manual' });
|
||||
now += 24 * 60 * 60_000;
|
||||
|
||||
assert.equal(record.pausedUntil, null);
|
||||
assert.deepEqual(controller.releaseExpired(), []);
|
||||
assert.deepEqual(controller.activeKeys(), ['doodstream.com:acc-1']);
|
||||
assert.equal(controller.reset('doodstream.com', 'acc-1'), true);
|
||||
assert.deepEqual(controller.activeKeys(), []);
|
||||
});
|
||||
|
||||
test('automatic expiry clears the runtime account and publishes the remaining state', () => {
|
||||
let now = 2_000;
|
||||
let scheduled;
|
||||
const cleared = [];
|
||||
const published = [];
|
||||
const controller = createAccountCooldownController({
|
||||
now: () => now,
|
||||
setTimer: (callback, delay) => { scheduled = { callback, delay }; return 1; },
|
||||
clearTimer: () => {},
|
||||
onClearAccount: (hoster, accountId) => cleared.push(`${hoster}:${accountId}`),
|
||||
onChange: (records, cause) => published.push({ records, cause })
|
||||
});
|
||||
const record = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
|
||||
now = record.pausedUntil;
|
||||
scheduled.callback();
|
||||
|
||||
assert.deepEqual(cleared, ['byse.sx:acc-1']);
|
||||
assert.deepEqual(published.at(-1), { records: [], cause: 'expired' });
|
||||
assert.deepEqual(controller.activeKeys(), []);
|
||||
});
|
||||
|
||||
test('account failure classification keeps credential and OTP errors manual', () => {
|
||||
const otp = new Error('Doodstream Login: OTP required');
|
||||
otp.otpRequired = true;
|
||||
assert.equal(classifyAccountFailure(otp), 'manual');
|
||||
assert.equal(classifyAccountFailure(new Error('VOE Login fehlgeschlagen: Falscher Username oder Passwort')), 'manual');
|
||||
assert.equal(classifyAccountFailure(new Error('HTTP 401 Unauthorized')), 'manual');
|
||||
assert.equal(classifyAccountFailure(new Error('Account banned')), 'manual');
|
||||
});
|
||||
|
||||
test('account failure classification cools down quota and rate-limit errors without pausing transient failures', () => {
|
||||
const quota = new Error('not enough disk space on your account');
|
||||
quota.accountError = true;
|
||||
assert.equal(classifyAccountFailure(quota), 'cooldown');
|
||||
assert.equal(classifyAccountFailure(new Error('HTTP 429 Too Many Requests')), 'cooldown');
|
||||
const transient = new Error('HTTP 503 Service Unavailable');
|
||||
transient.transientNetwork = true;
|
||||
assert.equal(classifyAccountFailure(transient), 'none');
|
||||
});
|
||||
|
||||
test('account failure classification does not punish unknown, confirmation, or bare WAF errors', () => {
|
||||
assert.equal(classifyAccountFailure(new Error('Upload wurde nicht bestätigt')), 'none');
|
||||
assert.equal(classifyAccountFailure(new Error('Unbekannter Parserfehler')), 'none');
|
||||
const waf = new Error('HTTP 403');
|
||||
waf.status = 403;
|
||||
assert.equal(classifyAccountFailure(waf), 'none');
|
||||
});
|
||||
|
||||
test('account failure classification cools down stale sessions and explicit account errors', () => {
|
||||
assert.equal(classifyAccountFailure(new Error('CSRF-Token nicht gefunden')), 'cooldown');
|
||||
assert.equal(classifyAccountFailure(new Error('Session expired')), 'cooldown');
|
||||
const accountError = new Error('Provider account unavailable');
|
||||
accountError.accountError = true;
|
||||
assert.equal(classifyAccountFailure(accountError), 'cooldown');
|
||||
assert.equal(classifyAccountFailure(new Error('Doodstream: sess_id nicht gefunden nach Login')), 'cooldown');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user