diff --git a/lib/hosters.js b/lib/hosters.js index 17c7d3e..1069243 100644 --- a/lib/hosters.js +++ b/lib/hosters.js @@ -325,11 +325,15 @@ async function apiGet(url, signal) { try { data = JSON.parse(text); } catch { - throw new Error(`API-Antwort war kein JSON (HTTP ${res.status}): ${(text || '').slice(0, 200)}`); + const err = new Error(`API-Antwort war kein JSON (HTTP ${res.status}): ${(text || '').slice(0, 200)}`); + if (res.status >= 500) err.transientNetwork = true; + throw err; } if (data.status && [401, 403, 429, 500].includes(data.status)) { - throw new Error(data.msg || data.message || JSON.stringify(data)); + const err = new Error(data.msg || data.message || JSON.stringify(data)); + if (data.status === 500) err.transientNetwork = true; + throw err; } return data; } finally { @@ -342,6 +346,7 @@ async function apiGet(url, signal) { async function getUploadServer(hosterName, hosterConfig, apiKey, signal) { let lastMessage = ''; + let lastTransient = false; for (let attempt = 1; attempt <= SERVER_RETRY_ATTEMPTS; attempt++) { for (const endpoint of hosterConfig.serverEndpoints) { @@ -361,6 +366,7 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) { } catch (err) { if (err.name === 'AbortError') throw err; if (err.message) lastMessage = err.message; + if (err.transientNetwork === true) lastTransient = true; } } @@ -394,6 +400,7 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) { // Genuine auth failures (invalid key / unauthorized / forbidden) make // shouldRetryServerLookup return false and stay classified as account errors. if (shouldRetryServerLookup(lastMessage)) e.hosterTransient = true; + if (lastTransient) e.transientNetwork = true; throw e; } throw new Error('Kein Upload-Server erhalten. API-Key pruefen.'); @@ -404,7 +411,7 @@ async function _fetchByseFileList(apiKey, signal) { // to match the upload we just did against what the server has. The API // shape is typical XFS: { status, msg, result: { files: [...] } } or // { status, msg, files: [...] }. - const url = `https://api.byse.sx/api/file/list?key=${encodeURIComponent(apiKey)}&per_page=100&sort=date&order=desc`; + const url = `https://api.byse.sx/file/list?key=${encodeURIComponent(apiKey)}&per_page=100&sort=date&order=desc`; try { const { body, statusCode } = await request(url, { method: 'GET', signal, @@ -573,9 +580,11 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro payload = rawBody ? JSON.parse(rawBody) : {}; } catch { const snippet = rawBody ? rawBody.slice(0, 240).replace(/\s+/g, ' ').trim() : ''; - throw new Error( + const err = new Error( `Upload-Antwort von ${hosterName} war kein JSON (HTTP ${statusCode}${snippet ? `): ${snippet}` : ')'}` ); + if (statusCode >= 500) err.transientNetwork = true; + throw err; } // Normalize valid-but-not-object JSON (JSON.parse('null') → null; // JSON.parse('"foo"') → string; JSON.parse('[1]') → array). Without this @@ -589,15 +598,19 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro } if (statusCode < 200 || statusCode >= 300) { - throw new Error( + const err = new Error( payload.msg || payload.message || `Upload fehlgeschlagen (HTTP ${statusCode}${headers?.['content-type'] ? `, ${headers['content-type']}` : ''})` ); + if (statusCode >= 500) err.transientNetwork = true; + throw err; } if (payload.status && [401, 403, 429, 500].includes(payload.status)) { - throw new Error(payload.msg || payload.message || JSON.stringify(payload)); + const err = new Error(payload.msg || payload.message || JSON.stringify(payload)); + if (payload.status === 500) err.transientNetwork = true; + throw err; } let result = null; diff --git a/lib/upload-manager.js b/lib/upload-manager.js index f88c026..af52573 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -131,6 +131,7 @@ class UploadManager extends EventEmitter { // which takes priority in _shouldSkipRetryOnAccountError. _isFileRejectedError(err) { if (!err) return false; + if (err.transientNetwork === true) return false; if (err.accountError === true) return false; // explicit account-level wins if (err.fileRejected === true) return true; if (!err.message) return false; @@ -161,7 +162,9 @@ class UploadManager extends EventEmitter { // out for this file without blacklisting the account, so other jobs in the // batch still get a fresh chance on it. _isTransientNetworkError(err) { - if (!err || !err.message) return false; + if (!err) return false; + if (err.transientNetwork === true) return true; + if (!err.message) return false; const m = String(err.message); const TRANSIENT = [ /ENOTFOUND/i, @@ -177,7 +180,11 @@ class UploadManager extends EventEmitter { /dns (lookup|error|failed)/i, /getaddrinfo/i, /fetch failed/i, - /\bconnect (ETIMEDOUT|ECONN)/i + /\bconnect (ETIMEDOUT|ECONN)/i, + /HTTP 5\d\d\b/i, + /Bad Gateway/i, + /Service Unavailable/i, + /Gateway Time-?out/i ]; return TRANSIENT.some(p => p.test(m)); } @@ -188,6 +195,7 @@ class UploadManager extends EventEmitter { // or out of quota. _shouldSkipRetryOnAccountError(err) { if (!err) return false; + if (err.transientNetwork === true) return false; // Explicit account-level flag from hoster parsers — highest priority. if (err.accountError === true) return true; if (!err.message) return false; @@ -982,6 +990,7 @@ class UploadManager extends EventEmitter { if (signal.aborted || this.stopAfterActive) break; if (this._isFileRejectedError(err)) break; if (this._isHosterTransientError(err)) break; + if (this._isTransientNetworkError(err)) break; if (attempt >= maxAttempts) break; } } diff --git a/tests/byse-reject-recovery.test.js b/tests/byse-reject-recovery.test.js index 9698b54..9740478 100644 --- a/tests/byse-reject-recovery.test.js +++ b/tests/byse-reject-recovery.test.js @@ -40,7 +40,7 @@ test('byse "Not video file format" (suspect) DOES poll recovery and claims the a let listCalls = 0; requestRouter = async (url, opts) => { const u = String(url); - if (/\/api\/file\/list/.test(u)) { + if (/\/file\/list/.test(u)) { listCalls++; const body = listCalls === 1 ? '{"status":200,"result":{"files":[]}}' @@ -68,7 +68,7 @@ test('byse "Not video file format" with empty poll throws err.suspectReject so r let listCalls = 0; requestRouter = async (url, opts) => { const u = String(url); - if (/\/api\/file\/list/.test(u)) { + if (/\/file\/list/.test(u)) { listCalls++; if (listCalls >= 2) abort.abort(); return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } }; @@ -95,7 +95,7 @@ test('byse "Not video file format" with probe-confirmed NON-video skips the reco let listCalls = 0; requestRouter = async (url, opts) => { const u = String(url); - if (/\/api\/file\/list/.test(u)) { + if (/\/file\/list/.test(u)) { listCalls++; return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } }; } @@ -122,7 +122,7 @@ test('byse explicit "Duplicate" rejection still throws fast WITHOUT recovery pol let listCalls = 0; requestRouter = async (url, opts) => { const u = String(url); - if (/\/api\/file\/list/.test(u)) { + if (/\/file\/list/.test(u)) { listCalls++; return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } }; } @@ -149,7 +149,7 @@ test('byse empty filecode WITHOUT explicit rejection still polls recovery', asyn let listCalls = 0; requestRouter = async (url, opts) => { const u = String(url); - if (/\/api\/file\/list/.test(u)) { + if (/\/file\/list/.test(u)) { listCalls++; const body = listCalls === 1 ? '{"status":200,"result":{"files":[]}}' @@ -170,3 +170,65 @@ test('byse empty filecode WITHOUT explicit rejection still polls recovery', asyn 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 () => '