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:
parent
8f500c590e
commit
f237d0f97a
@ -25,6 +25,7 @@ export default [
|
||||
URL: 'readonly',
|
||||
fetch: 'readonly',
|
||||
AbortController: 'readonly',
|
||||
AbortSignal: 'readonly',
|
||||
navigator: 'readonly',
|
||||
document: 'readonly',
|
||||
window: 'readonly',
|
||||
|
||||
@ -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,7 +312,9 @@ class DoodstreamUploader {
|
||||
yield epilogueBuf;
|
||||
}
|
||||
|
||||
const uploadRes = await request(uploadUrl, {
|
||||
let uploadRes;
|
||||
try {
|
||||
uploadRes = await request(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
@ -309,6 +327,16 @@ class DoodstreamUploader {
|
||||
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,6 +433,8 @@ class DoodstreamUploader {
|
||||
|
||||
_debugLog(`Submitting upload_result to ${BASE_URL}/ with fields: ${JSON.stringify(hiddenFields)}`);
|
||||
const formData = new URLSearchParams(hiddenFields);
|
||||
let followText = '';
|
||||
try {
|
||||
const followRes = await this._fetch(BASE_URL + '/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@ -413,7 +443,18 @@ class DoodstreamUploader {
|
||||
},
|
||||
body: formData.toString()
|
||||
});
|
||||
const followText = await followRes.text();
|
||||
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
|
||||
|
||||
@ -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 };
|
||||
|
||||
Loading…
Reference in New Issue
Block a user