A single byse.sx gateway hiccup (HTTP 502 "<!doctype html>..." or a mid-upload
ECONNRESET) was cascading through the whole failover chain and blacklisting every
account for the batch. Root cause: the upload-POST throw sites threw PLAIN errors
with no classification, so a 502 was treated as a GENERIC error -> mark-failed ->
emit('account-failed') -> failover to the next account (which hits the same
gateway) -> repeat until the chain is exhausted, then poison sibling files in the
batch via the blacklist. The screenshots showed exactly this: "Primär ... Fallback
#3" all failing with the same 502. byse did NOT change their API (the upload
contract still matches their docs and was live-probed: GET /upload/server -> POST
{key}+file -> files[0].filecode); these are transient infrastructure failures.
A 5xx gateway error is not an account fault: every account hits the same gateway,
so failing over is pointless and blacklisting is harmful. Fail open instead — retry
the SAME account and, if byse stays down, fail the file cleanly without touching the
account or the rotation cursor.
- upload-manager: _isTransientNetworkError() now honors an explicit err.transientNetwork
flag (checked before the empty-message guard) and, as a defensive fallback, matches
/HTTP 5\d\d/, Bad Gateway, Service Unavailable, Gateway Time-out. The flag is made
authoritative in _isFileRejectedError and _shouldSkipRetryOnAccountError (both return
early when it is set) so a 5xx whose HTML body happens to contain a rejection/auth
keyword can never be mis-binned as file/account. The post-rotation retry loop also
breaks on a transient error (parity with the primary loop) to avoid burning the
retry budget re-uploading on a fallback.
- hosters: the upload POST throw sites tag err.transientNetwork when statusCode >= 500
(non-JSON body and non-2xx-with-JSON) and when the 2xx status-envelope carries
status:500; 401/403/429 stay PLAIN so they remain account errors. The server-lookup
path (apiGet/getUploadServer) is hardened symmetrically: a 5xx there tags
transientNetwork and getUploadServer preserves the flag onto its wrapped error, so
the heuristic shouldRetryServerLookup() can no longer be defeated by a 5xx body that
contains an auth keyword.
- hosters: fixed a separate real bug surfaced during investigation — _fetchByseFileList
built the wrong URL https://api.byse.sx/api/file/list (the /api/ prefix is correct
only for doodstream's doodapi.co host; byse already carries the api. subdomain).
Live-probed: /api/file/list 302-redirects to the docs page, /file/list returns 200
JSON. The wrong path made the byse async-recovery poll silently dead (always []),
removing the safety net that reclaims a large file that registered despite a 502.
Now https://api.byse.sx/file/list.
ECONNRESET was already transient and is unchanged. doodstream's 2xx empty-form stays
hosterTransient (one attempt, no re-upload). vidmoly/clouddrop use their own uploaders
and are unaffected; doodstream/voe apiKey uploads share uploadFile and correctly
benefit from the same 5xx-is-infra logic.
Deferred follow-up: poll-first-on-5xx dedup (route a 5xx through the now-working
recovery poll before retrying, to reclaim a registered-but-502 file instead of
re-uploading). It does not add new dupe risk vs the prior cascade and a pure 502
means the backend was never reached, so retry-same-account is dupe-safe for it.
Investigated and reviewed by two multi-agent workflows (4-lens investigation with a
synthesized fix spec; 4-lens adversarial review with per-finding verification): no
API drift, 0 confirmed defects. Tests: classifier units (flag-above-message-guard,
5xx-transient, 4xx-stay-account), an end-to-end 502/503/500-envelope tag through
uploadFile, and a 502-retries-same-account-no-cascade integration case. Suite 311/311.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
235 lines
9.3 KiB
JavaScript
235 lines
9.3 KiB
JavaScript
const { test, before, after } = require('node:test');
|
|
const assert = require('node:assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
let requestRouter = async () => ({ statusCode: 200, headers: {}, body: { text: async () => '{}' } });
|
|
const undici = require('undici');
|
|
const _origUndiciRequest = undici.request;
|
|
undici.request = (...a) => requestRouter(...a);
|
|
delete require.cache[require.resolve('../lib/hosters')];
|
|
const hostersMod = require('../lib/hosters');
|
|
const { uploadFile } = hostersMod;
|
|
|
|
let tmpFile;
|
|
let origFetch;
|
|
before(() => {
|
|
tmpFile = path.join(os.tmpdir(), `byse-itest-${process.pid}.mkv`);
|
|
fs.writeFileSync(tmpFile, Buffer.alloc(2048, 7));
|
|
origFetch = global.fetch;
|
|
});
|
|
after(() => {
|
|
global.fetch = origFetch;
|
|
undici.request = _origUndiciRequest;
|
|
delete require.cache[require.resolve('../lib/hosters')];
|
|
try { fs.unlinkSync(tmpFile); } catch {}
|
|
});
|
|
|
|
function stubByseUploadServer() {
|
|
global.fetch = async (url) => {
|
|
if (/upload\/server/.test(String(url))) {
|
|
return { status: 200, text: async () => JSON.stringify({ status: 200, result: 'https://node1.byse.sx/upload/01' }) };
|
|
}
|
|
return { status: 200, text: async () => '{"status":200}' };
|
|
};
|
|
}
|
|
|
|
test('byse "Not video file format" (suspect) DOES poll recovery and claims the async-registered file', async () => {
|
|
stubByseUploadServer();
|
|
let listCalls = 0;
|
|
requestRouter = async (url, opts) => {
|
|
const u = String(url);
|
|
if (/\/file\/list/.test(u)) {
|
|
listCalls++;
|
|
const body = listCalls === 1
|
|
? '{"status":200,"result":{"files":[]}}'
|
|
: JSON.stringify({ status: 200, result: { files: [{ file_code: 'BIGMKV77', title: path.basename(tmpFile) }] } });
|
|
return { statusCode: 200, headers: {}, body: { text: async () => body } };
|
|
}
|
|
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
|
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
|
}
|
|
return {
|
|
statusCode: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK', files: [{ filecode: '', filename: 'x.mkv', status: 'Not video file format' }] }) }
|
|
};
|
|
};
|
|
|
|
const res = await uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null);
|
|
assert.strictEqual(res.file_code, 'BIGMKV77');
|
|
assert.ok(listCalls >= 2, 'suspect rejection must still run the recovery poll (live 2026-06-09: >2.7GB MKVs got this status while registering fine)');
|
|
});
|
|
|
|
test('byse "Not video file format" with empty poll throws err.suspectReject so rotation can try other accounts', async () => {
|
|
stubByseUploadServer();
|
|
const abort = new AbortController();
|
|
let listCalls = 0;
|
|
requestRouter = async (url, opts) => {
|
|
const u = String(url);
|
|
if (/\/file\/list/.test(u)) {
|
|
listCalls++;
|
|
if (listCalls >= 2) abort.abort();
|
|
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
|
|
}
|
|
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
|
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
|
}
|
|
return {
|
|
statusCode: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK', files: [{ filecode: '', filename: 'x.mkv', status: 'Not video file format' }] }) }
|
|
};
|
|
};
|
|
|
|
await assert.rejects(
|
|
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, abort.signal, null),
|
|
(err) => err.fileRejected === true && err.suspectReject === true && /Not video file format/i.test(err.message)
|
|
);
|
|
assert.ok(listCalls >= 2, 'poll must have started before giving up');
|
|
});
|
|
|
|
test('byse "Not video file format" with probe-confirmed NON-video skips the recovery poll (genuine rejection)', async () => {
|
|
stubByseUploadServer();
|
|
let listCalls = 0;
|
|
requestRouter = async (url, opts) => {
|
|
const u = String(url);
|
|
if (/\/file\/list/.test(u)) {
|
|
listCalls++;
|
|
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
|
|
}
|
|
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
|
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
|
}
|
|
return {
|
|
statusCode: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK', files: [{ filecode: '', filename: 'x.mkv', status: 'Not video file format' }] }) }
|
|
};
|
|
};
|
|
|
|
await assert.rejects(
|
|
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null, { probeIsVideoLike: false }),
|
|
(err) => err.fileRejected === true && /Not video file format/i.test(err.message)
|
|
);
|
|
|
|
assert.strictEqual(listCalls, 1, 'probe says non-video → the rejection is genuine, no 15-attempt poll');
|
|
});
|
|
|
|
test('byse explicit "Duplicate" rejection still throws fast WITHOUT recovery polling', async () => {
|
|
stubByseUploadServer();
|
|
let listCalls = 0;
|
|
requestRouter = async (url, opts) => {
|
|
const u = String(url);
|
|
if (/\/file\/list/.test(u)) {
|
|
listCalls++;
|
|
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
|
|
}
|
|
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
|
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
|
}
|
|
return {
|
|
statusCode: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK', files: [{ filecode: '', filename: 'x.mkv', status: 'Duplicate' }] }) }
|
|
};
|
|
};
|
|
|
|
await assert.rejects(
|
|
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
|
|
(err) => err.fileRejected === true && err.suspectReject !== true && /Duplicate/i.test(err.message)
|
|
);
|
|
|
|
assert.strictEqual(listCalls, 1, 'file/list should be hit ONCE (baseline only) — no 15-attempt recovery poll on a genuine rejection');
|
|
});
|
|
|
|
test('byse empty filecode WITHOUT explicit rejection still polls recovery', async () => {
|
|
stubByseUploadServer();
|
|
let listCalls = 0;
|
|
requestRouter = async (url, opts) => {
|
|
const u = String(url);
|
|
if (/\/file\/list/.test(u)) {
|
|
listCalls++;
|
|
const body = listCalls === 1
|
|
? '{"status":200,"result":{"files":[]}}'
|
|
: JSON.stringify({ status: 200, result: { files: [{ file_code: 'RECOVERED99', title: path.basename(tmpFile) }] } });
|
|
return { statusCode: 200, headers: {}, body: { text: async () => body } };
|
|
}
|
|
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
|
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
|
}
|
|
return {
|
|
statusCode: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK' }) }
|
|
};
|
|
};
|
|
|
|
const res = await uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null);
|
|
assert.strictEqual(res.file_code, 'RECOVERED99');
|
|
assert.ok(listCalls >= 2, 'recovery polling must run when there is no explicit rejection');
|
|
});
|
|
|
|
function stubBysePost(response) {
|
|
requestRouter = async (url, opts) => {
|
|
const u = String(url);
|
|
if (/\/file\/list/.test(u)) {
|
|
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
|
|
}
|
|
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
|
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
|
}
|
|
return response();
|
|
};
|
|
}
|
|
|
|
test('byse upload POST 502 (HTML gateway body) is tagged transientNetwork', async () => {
|
|
stubByseUploadServer();
|
|
stubBysePost(() => ({
|
|
statusCode: 502,
|
|
headers: { 'content-type': 'text/html' },
|
|
body: { text: async () => '<!doctype html><html><head><title>502 Bad Gateway</title></head><body>502 Bad Gateway</body></html>' }
|
|
}));
|
|
await assert.rejects(
|
|
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
|
|
(err) => err.transientNetwork === true && /kein JSON \(HTTP 502\)/.test(err.message)
|
|
);
|
|
});
|
|
|
|
test('byse upload POST non-2xx JSON 503 is tagged transientNetwork', async () => {
|
|
stubByseUploadServer();
|
|
stubBysePost(() => ({
|
|
statusCode: 503,
|
|
headers: { 'content-type': 'application/json' },
|
|
body: { text: async () => JSON.stringify({ status: 503, msg: 'Service Unavailable' }) }
|
|
}));
|
|
await assert.rejects(
|
|
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
|
|
(err) => err.transientNetwork === true
|
|
);
|
|
});
|
|
|
|
test('byse upload POST 2xx envelope {status:500} is transient; {status:403} stays account-level', async () => {
|
|
stubByseUploadServer();
|
|
stubBysePost(() => ({
|
|
statusCode: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
body: { text: async () => JSON.stringify({ status: 500, msg: 'Internal Server Error' }) }
|
|
}));
|
|
await assert.rejects(
|
|
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
|
|
(err) => err.transientNetwork === true
|
|
);
|
|
|
|
stubBysePost(() => ({
|
|
statusCode: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
body: { text: async () => JSON.stringify({ status: 403, msg: 'Forbidden' }) }
|
|
}));
|
|
await assert.rejects(
|
|
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
|
|
(err) => err.transientNetwork !== true
|
|
);
|
|
});
|