Treat dropped download connections as status 0 again

Since HTTP.download() switched to fetch() in 10.0, a connection that
dropped mid-download threw a TypeError ("Error in input stream"), so
ZFS sync no longer retried interrupted S3 downloads and failed the file
sync immediately.

https://forums.zotero.org/discussion/134446/sync-error

(cherry picked from commit d85ec125be)
This commit is contained in:
Dan Stillman 2026-10-26 11:37:14 -04:00
parent a4b7102b94
commit fffea9717c
2 changed files with 44 additions and 2 deletions

View file

@ -727,6 +727,14 @@ Zotero.HTTP = new function () {
}, timeout);
}
// Report a dropped connection as status 0, as request() does, so that callers and
// _retryOnServerError() handle it the same way
function connectionError(e) {
let msg = "HTTP GET " + ctx.dispURL + " failed: " + e;
Zotero.debug(msg, 1);
return new Zotero.HTTP.UnexpectedStatusException({ status: 0 }, url, msg);
}
let response;
try {
response = await fetch(url, fetchOptions);
@ -737,7 +745,7 @@ Zotero.HTTP = new function () {
if (controller.signal.aborted) {
throw new Zotero.HTTP.TimeoutException(timeout);
}
throw e;
throw connectionError(e);
}
clearTimeout(connectTimerID);
@ -777,7 +785,9 @@ Zotero.HTTP = new function () {
await IOUtils.write(path, new Uint8Array());
while (true) {
resetInactivityTimer();
let { done, value } = await reader.read();
let { done, value } = await reader.read().catch((e) => {
throw connectionError(e);
});
if (done) break;
await IOUtils.write(path, value, { mode: 'append' });
totalBytes += value.byteLength;

View file

@ -424,6 +424,20 @@ describe("Zotero.HTTP", function () {
}
}
);
httpd.registerPathHandler(
'/download/truncated',
{
handle: function (request, response) {
response.seizePower();
response.write("HTTP/1.1 200 OK\r\n"
+ "Content-Type: application/octet-stream\r\n"
+ "Content-Length: 10000\r\n"
+ "\r\n"
+ "x".repeat(100));
response.finish();
}
}
);
httpd.registerPathHandler(
'/download/redirect-to-file',
{
@ -564,6 +578,24 @@ describe("Zotero.HTTP", function () {
assert.isFalse(await IOUtils.exists(dest));
});
it("should throw UnexpectedStatusException with status 0 if the connection is refused", async function () {
let dest = PathUtils.join(tmpDir, "refused.bin");
let e = await getPromiseError(
Zotero.HTTP.download("http://127.0.0.1:1/", dest)
);
assert.instanceOf(e, Zotero.HTTP.UnexpectedStatusException);
assert.equal(e.status, 0);
});
it("should throw UnexpectedStatusException with status 0 if the connection drops during the download", async function () {
let dest = PathUtils.join(tmpDir, "truncated.bin");
let e = await getPromiseError(
Zotero.HTTP.download(baseURL + "download/truncated", dest)
);
assert.instanceOf(e, Zotero.HTTP.UnexpectedStatusException);
assert.equal(e.status, 0);
});
it("should allow non-success status with successCodes", async function () {
let dest = PathUtils.join(tmpDir, "404.bin");
let req = await Zotero.HTTP.download(