From f237d0f97a5730033f847e7755263de7fd3f722f Mon Sep 17 00:00:00 2001 From: Administrator Date: Mon, 25 May 2026 00:44:20 +0200 Subject: [PATCH] fix(doodstream): survive transient network blips around the upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After 3.3.26 fixed the filecode parsing, the remaining intermittent failure is a generic "fetch failed" — a transient network error on one of the requests around the multi-minute upload. Can't tell from one log line whether it's the server-discovery GET or the post-upload result-submit, so harden both: - _fetch (the native-fetch chokepoint for discovery, redirects, result-submit): retry up to 3x with short backoff on a thrown network error, each attempt bounded by a 20s timeout (Node fetch has none by default). Caller aborts are not retried. The big file upload (undici) is retried at the upload-manager level, not here. - result-submit is now best-effort: if it still fails after retries but we already hold the filecode from the CDN response, return that instead of discarding a completed upload. - label the undici upload-POST error with phase + MB sent + node, preserving the original message so transient classification still matches. - eslint: add AbortSignal to globals. - Tests: _fetch transient-retry path (10 doodstream tests total). "fetch failed" is already classified transient by upload-manager, so this is additive resilience; next logs will show if anything still slips through. Co-Authored-By: Claude Opus 4.7 --- eslint.config.mjs | 1 + lib/doodstream-upload.js | 95 +++++++++++++++++++++++---------- tests/doodstream-upload.test.js | 19 +++++++ 3 files changed, 88 insertions(+), 27 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 4a53a64..63f2b15 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,6 +25,7 @@ export default [ URL: 'readonly', fetch: 'readonly', AbortController: 'readonly', + AbortSignal: 'readonly', navigator: 'readonly', document: 'readonly', window: 'readonly', diff --git a/lib/doodstream-upload.js b/lib/doodstream-upload.js index d9a6a2b..db46be7 100644 --- a/lib/doodstream-upload.js +++ b/lib/doodstream-upload.js @@ -76,11 +76,27 @@ class DoodstreamUploader { headers['Cookie'] = this._cookieHeader(); } - const res = await fetch(url, { - ...opts, - headers, - redirect: 'manual' - }); + // The small discovery/result requests that bookend a multi-minute upload + // occasionally hit a transient blip ("fetch failed", ECONNRESET, a hung TLS + // handshake). A blip here shouldn't throw away the whole upload, so retry a + // few times with short backoff. Each attempt gets its own 20s timeout — + // Node's fetch has none by default, and a hung socket would otherwise stall + // the attempt for minutes. The big file upload (undici) is retried at the + // upload-manager level, not here. + let res; + for (let attempt = 1; ; attempt++) { + const timeoutSignal = AbortSignal.timeout(20000); + const signal = opts.signal ? AbortSignal.any([opts.signal, timeoutSignal]) : timeoutSignal; + try { + res = await fetch(url, { ...opts, headers, redirect: 'manual', signal }); + break; + } catch (err) { + if (opts.signal && opts.signal.aborted) throw err; // caller abort: don't retry + if (attempt >= 3) throw err; + _debugLog(`_fetch transient (${attempt}/3) ${url}: ${err && err.message}; retry`); + await new Promise(r => setTimeout(r, 400 * attempt)); + } + } this._parseCookiesFromHeaders(res.headers); @@ -296,19 +312,31 @@ class DoodstreamUploader { yield epilogueBuf; } - const uploadRes = await request(uploadUrl, { - method: 'POST', - headers: { - 'Content-Type': `multipart/form-data; boundary=${boundary}`, - 'Content-Length': String(totalSize), - 'User-Agent': USER_AGENT, - 'Cookie': this._cookieHeader() - }, - body: generate(), - signal, - bodyTimeout: UPLOAD_TIMEOUT, - headersTimeout: 60000 - }); + let uploadRes; + try { + uploadRes = await request(uploadUrl, { + method: 'POST', + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': String(totalSize), + 'User-Agent': USER_AGENT, + 'Cookie': this._cookieHeader() + }, + body: generate(), + signal, + bodyTimeout: UPLOAD_TIMEOUT, + headersTimeout: 60000 + }); + } catch (err) { + // Label which phase failed so a future "fetch failed"/"terminated" is + // attributable to the big upload POST vs the small bookend requests. The + // original message is preserved as a substring so upload-manager's + // transient classification still matches. NOTE: undici may surface + // "terminated"/"other side closed", which are not yet in that transient + // list — revisit if logs show them. + const mb = Math.round(bytesRead / 1048576); + throw new Error(`Doodstream Upload-POST (${mb} MB an ${uploadUrl}): ${err && err.message ? err.message : err}`); + } const statusCode = uploadRes.statusCode; _debugLog(`Upload response status: ${statusCode}`); @@ -405,15 +433,28 @@ class DoodstreamUploader { _debugLog(`Submitting upload_result to ${BASE_URL}/ with fields: ${JSON.stringify(hiddenFields)}`); const formData = new URLSearchParams(hiddenFields); - const followRes = await this._fetch(BASE_URL + '/', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Referer': BASE_URL + '/' - }, - body: formData.toString() - }); - const followText = await followRes.text(); + let followText = ''; + try { + const followRes = await this._fetch(BASE_URL + '/', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Referer': BASE_URL + '/' + }, + body: formData.toString() + }); + followText = await followRes.text(); + } catch (err) { + // The file already uploaded to the CDN; this POST only registers it on + // doodstream's side. If it fails transiently (even after _fetch's own + // retries) but we already hold the filecode, the upload succeeded from + // the user's view — return it rather than discarding a done upload. + if (fnCode && fnCode.length >= 8) { + _debugLog(`upload_result submit failed (${err && err.message}); using fn ${fnCode}`); + return this._buildResult(fnCode); + } + throw err; + } _debugLog(`upload_result response (first 500): ${followText.slice(0, 500)}`); // Try to find filecode in result page diff --git a/tests/doodstream-upload.test.js b/tests/doodstream-upload.test.js index 8f315aa..84ddf36 100644 --- a/tests/doodstream-upload.test.js +++ b/tests/doodstream-upload.test.js @@ -64,6 +64,25 @@ test('happy path: link in result page wins', async () => { assert.equal(res.file_code, 'jjsuhr931ds9'); }); +// --- _fetch: transient network blips on the small requests self-heal --- +test('_fetch retries a transient network failure then succeeds', async () => { + const up = new DoodstreamUploader(); + const origFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = async () => { + calls++; + if (calls === 1) throw new TypeError('fetch failed'); + return { status: 200, headers: { getSetCookie: () => [], get: () => null }, text: async () => 'ok' }; + }; + try { + const res = await up._fetch('https://example.test/x'); + assert.equal(calls, 2); // failed once, retried, succeeded + assert.equal(await res.text(), 'ok'); + } finally { + globalThis.fetch = origFetch; + } +}); + // --- _getUploadServer: discovery must never fall back to a hardcoded node --- function fakeRes(body, { status = 200, ctype = 'text/html' } = {}) { return { status, headers: { get: (h) => (h.toLowerCase() === 'content-type' ? ctype : null) }, text: async () => body };