perf(rotation): rotate after 1 fail on generic errors, not after 5

Before: a non-transient / non-file-rejected / non-account-specific
error (e.g. "VOE Upload: <any generic message>") would burn the full
retries-per-account budget on the primary before the rotation logic
even kicked in. On retries=5 that's "Retry 2/5 Primär", "Retry 3/5
Primär", … all on the same broken account before the fallback gets
a shot.

Now:
- main.js pre-resolves the next fallback for every hoster at batch-
  start (stored in _accountOverrides via the existing session cache +
  primeOverrides path). Pre-job-swap still ignores it until the
  primary is actually marked failed, so jobs still begin on primary.
- upload-manager.js: in the retry loop's generic error branch,
  _hasPendingOverride() checks whether a usable fallback is ready.
  If yes and the error is NOT transient (transient = network glitch
  = retry same acc), break out to rotation. Marks primary failed,
  rotates to acc2, retries there.
- Result: for a 2-account hoster, worst case is 1 attempt on primary
  + retries-per-account on fallback, instead of N × 2. Transient
  network errors (ENOTFOUND / ECONNRESET / socket hang up) keep the
  old "retry same account" semantics because the network is the
  issue, not the account.
- Single-account hosters: unchanged. No pending override = classic
  retry-on-same-account until exhausted.

3 new tests pin: generic + override → rotate on attempt 1; transient
+ override → stay on same acc; no override → classic retry. 87/87
green.
This commit is contained in:
Administrator 2026-04-22 18:23:30 +02:00
parent c70f105685
commit 3553666d9d
3 changed files with 134 additions and 0 deletions

View File

@ -64,6 +64,19 @@ class UploadManager extends EventEmitter {
return this._accountOverrides.get(hoster) || null; return this._accountOverrides.get(hoster) || null;
} }
// True if the hoster has a usable override stored that differs from the
// account currently in the task and isn't itself already marked failed.
// Used by the retry loop to decide "retry on same account vs break to
// rotation" — skipping wasted attempts on a likely-bad primary when a
// pre-resolved fallback is ready to try.
_hasPendingOverride(hoster, currentAccountId) {
const override = this._accountOverrides.get(hoster);
if (!override) return false;
if (override.id === currentAccountId) return false;
if (this._failedAccounts.has(hoster + ':' + override.id)) return false;
return true;
}
_rotLog(event, data) { _rotLog(event, data) {
this.emit('rot-log', { ts: Date.now(), event, ...data }); this.emit('rot-log', { ts: Date.now(), event, ...data });
} }
@ -595,6 +608,19 @@ class UploadManager extends EventEmitter {
}); });
break; break;
} }
// Generic non-transient error AND a fallback is already resolved for
// this hoster: bail to rotation instead of burning more retries on a
// possibly-dead primary. The fallback (pre-resolved at batch-start)
// deserves a real shot. Transient network errors stay on the same
// account — the network is the issue, not the account.
if (!this._isTransientNetworkError(err) &&
this._hasPendingOverride(task.hoster, task.accountId)) {
this._rotLog('try-alternate-after-fail', {
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
attempt, error: err && err.message ? err.message : String(err)
});
break;
}
if (attempt >= maxAttempts) break; if (attempt >= maxAttempts) break;
// Wait 3 seconds before retry // Wait 3 seconds before retry
await this._sleep(3000, signal); await this._sleep(3000, signal);

20
main.js
View File

@ -1133,6 +1133,26 @@ ipcMain.handle('start-upload', (_event, payload) => {
if (tasks.length === 0) return { error: 'Keine gültigen Zugangsdaten für die gewählten Hoster.', skippedJobs }; if (tasks.length === 0) return { error: 'Keine gültigen Zugangsdaten für die gewählten Hoster.', skippedJobs };
// Pre-resolve a fallback for every hoster that has one. Lets the upload
// manager break out of the retry loop after a single generic failure and
// try the alternate account immediately, instead of hammering a probably-
// dead primary 5× before the account-failed event even fires. Doesn't
// trigger pre-job-swap (which only fires when the current account is in
// _failedAccounts), so jobs still start on the primary as expected.
const hostersInBatch = new Set(tasks.map(t => t.hoster).filter(Boolean));
for (const hoster of hostersInBatch) {
if (_sessionAccountOverrides.has(hoster)) continue; // already learned from past batch
const accounts = config.hosters && config.hosters[hoster];
if (!Array.isArray(accounts) || accounts.length < 2) continue;
const primary = accounts.find(a => a && a.enabled !== false && hosterAccountHasCreds(hoster, a));
if (!primary) continue;
const next = getNextFallbackAccount(config, hoster, primary.id);
if (next) {
_sessionAccountOverrides.set(hoster, next);
rotLog(`main: pre-resolved fallback for ${hoster}${next.id} (primary ${primary.id} will try acc2 on first failure)`);
}
}
// Fresh collector for this new batch — old entries from the previous // Fresh collector for this new batch — old entries from the previous
// batch's jobs are dropped (user's signal for "fresh log" is starting a // batch's jobs are dropped (user's signal for "fresh log" is starting a
// new upload; addJobs during a running batch keeps them). // new upload; addJobs during a running batch keeps them).

View File

@ -692,6 +692,94 @@ describe('UploadManager', () => {
assert.equal(acc1Calls, 0, 'primed-dead acc1 must not receive any upload attempts'); assert.equal(acc1Calls, 0, 'primed-dead acc1 must not receive any upload attempts');
}); });
it('generic error + pre-resolved override: rotates after 1 attempt (no more 5x on primary)', async () => {
// acc1 throws a generic non-transient, non-account-specific error.
// acc2 succeeds. With a pre-resolved override (from main.js at batch
// start), the retry loop must break after 1 attempt on acc1 and rotate.
let acc1Calls = 0;
let acc2Calls = 0;
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
if (apiKey === 'acc1-key') {
acc1Calls++;
throw new Error('VOE Upload: irgendein generischer Fehler');
}
acc2Calls++;
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
});
const mgr = new UploadManager(
{ 'voe.sx': { retries: 5, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
);
const events = [];
mgr.on('rot-log', (e) => events.push(e.event));
await mgr.startBatch([
{ file: '/test/a.mp4', hoster: 'voe.sx', apiKey: 'acc1-key', accountId: 'acc1' }
], {
// Main.js pre-resolves the fallback at batch start.
primeOverrides: [['voe.sx', { id: 'acc2', apiKey: 'acc2-key' }]]
});
assert.equal(acc1Calls, 1, 'acc1 must get exactly 1 attempt before rotation kicks in');
assert.ok(acc2Calls >= 1, 'acc2 must take over');
assert.ok(events.includes('try-alternate-after-fail'),
`expected try-alternate-after-fail; got: ${events.join(',')}`);
});
it('transient error + pre-resolved override: retries SAME acc (network, not acc, is the issue)', async () => {
let acc1Calls = 0;
let acc2Calls = 0;
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
if (apiKey === 'acc1-key') {
acc1Calls++;
if (acc1Calls <= 2) throw new Error('connect ECONNRESET 1.2.3.4:443');
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok-on-acc1-retry', embed_url: null, file_code: 'ok' };
}
acc2Calls++;
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
});
const mgr = new UploadManager(
{ 'voe.sx': { retries: 5, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
);
const events = [];
mgr.on('rot-log', (e) => events.push(e.event));
await mgr.startBatch([
{ file: '/test/a.mp4', hoster: 'voe.sx', apiKey: 'acc1-key', accountId: 'acc1' }
], {
primeOverrides: [['voe.sx', { id: 'acc2', apiKey: 'acc2-key' }]]
});
assert.equal(acc1Calls, 3, 'transient must retry same acc until success');
assert.equal(acc2Calls, 0, 'must NOT rotate away on transient network errors');
assert.ok(!events.includes('try-alternate-after-fail'),
`transient should NOT trigger try-alternate-after-fail; got: ${events.join(',')}`);
});
it('generic error + NO override: falls back to classic retry on same account', async () => {
let acc1Calls = 0;
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
acc1Calls++;
throw new Error('something generic');
});
const mgr = new UploadManager(
{ 'voe.sx': { retries: 3, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
);
await mgr.startBatch([
{ file: '/test/a.mp4', hoster: 'voe.sx', apiKey: 'acc1-key', accountId: 'acc1' }
]);
// retries=3 → maxAttempts=4 (retries + 1). Without an override to rotate
// to, must exhaust all 4 attempts on acc1.
assert.equal(acc1Calls, 4, 'single-account hoster must retry N+1 times on same account');
});
it('startBatch without prime opts still clears state (back-compat)', async () => { it('startBatch without prime opts still clears state (back-compat)', async () => {
const mgr = new UploadManager({}); const mgr = new UploadManager({});
mgr._failedAccounts.set('byse.sx:acc1', true); mgr._failedAccounts.set('byse.sx:acc1', true);