fix(doodstream): survive transient network blips around the upload

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 <noreply@anthropic.com>
This commit is contained in:
Administrator 2026-05-25 00:44:20 +02:00
parent 8f500c590e
commit f237d0f97a
3 changed files with 88 additions and 27 deletions

View File

@ -25,6 +25,7 @@ export default [
URL: 'readonly', URL: 'readonly',
fetch: 'readonly', fetch: 'readonly',
AbortController: 'readonly', AbortController: 'readonly',
AbortSignal: 'readonly',
navigator: 'readonly', navigator: 'readonly',
document: 'readonly', document: 'readonly',
window: 'readonly', window: 'readonly',

View File

@ -76,11 +76,27 @@ class DoodstreamUploader {
headers['Cookie'] = this._cookieHeader(); headers['Cookie'] = this._cookieHeader();
} }
const res = await fetch(url, { // The small discovery/result requests that bookend a multi-minute upload
...opts, // occasionally hit a transient blip ("fetch failed", ECONNRESET, a hung TLS
headers, // handshake). A blip here shouldn't throw away the whole upload, so retry a
redirect: 'manual' // 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); this._parseCookiesFromHeaders(res.headers);
@ -296,19 +312,31 @@ class DoodstreamUploader {
yield epilogueBuf; yield epilogueBuf;
} }
const uploadRes = await request(uploadUrl, { let uploadRes;
method: 'POST', try {
headers: { uploadRes = await request(uploadUrl, {
'Content-Type': `multipart/form-data; boundary=${boundary}`, method: 'POST',
'Content-Length': String(totalSize), headers: {
'User-Agent': USER_AGENT, 'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Cookie': this._cookieHeader() 'Content-Length': String(totalSize),
}, 'User-Agent': USER_AGENT,
body: generate(), 'Cookie': this._cookieHeader()
signal, },
bodyTimeout: UPLOAD_TIMEOUT, body: generate(),
headersTimeout: 60000 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; const statusCode = uploadRes.statusCode;
_debugLog(`Upload response status: ${statusCode}`); _debugLog(`Upload response status: ${statusCode}`);
@ -405,15 +433,28 @@ class DoodstreamUploader {
_debugLog(`Submitting upload_result to ${BASE_URL}/ with fields: ${JSON.stringify(hiddenFields)}`); _debugLog(`Submitting upload_result to ${BASE_URL}/ with fields: ${JSON.stringify(hiddenFields)}`);
const formData = new URLSearchParams(hiddenFields); const formData = new URLSearchParams(hiddenFields);
const followRes = await this._fetch(BASE_URL + '/', { let followText = '';
method: 'POST', try {
headers: { const followRes = await this._fetch(BASE_URL + '/', {
'Content-Type': 'application/x-www-form-urlencoded', method: 'POST',
'Referer': BASE_URL + '/' headers: {
}, 'Content-Type': 'application/x-www-form-urlencoded',
body: formData.toString() 'Referer': BASE_URL + '/'
}); },
const followText = await followRes.text(); 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)}`); _debugLog(`upload_result response (first 500): ${followText.slice(0, 500)}`);
// Try to find filecode in result page // Try to find filecode in result page

View File

@ -64,6 +64,25 @@ test('happy path: link in result page wins', async () => {
assert.equal(res.file_code, 'jjsuhr931ds9'); 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 --- // --- _getUploadServer: discovery must never fall back to a hardcoded node ---
function fakeRes(body, { status = 200, ctype = 'text/html' } = {}) { function fakeRes(body, { status = 200, ctype = 'text/html' } = {}) {
return { status, headers: { get: (h) => (h.toLowerCase() === 'content-type' ? ctype : null) }, text: async () => body }; return { status, headers: { get: (h) => (h.toLowerCase() === 'content-type' ? ctype : null) }, text: async () => body };