fix(upload): retry on dropped connections — the SDK reports XHR network failures as AbortError, which we treated as a user cancel (#172)
CI / Build & Quality Checks (push) Successful in 1m54s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 2m6s

matrix-js-sdk rejects an upload whose XHR ends with status 0 (offline,
connection reset, DNS) with DOMException('AbortError') to mimic fetch,
the same name mx.cancelUpload() produces. isRetryableUploadError bailed
on any AbortError, so the one failure class the retry loop was built
for was never retried. Decide by our own cancel AbortSignal instead.

Verified with Playwright routing the upload endpoint: 502 → network drop
→ ok now completes in 3 attempts (1 s, 2 s back-off) and the image sends;
413 still fails fast after 1 attempt; persistent 503 gives up after 4.
Unit tests in utils/uploadRetry.test.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-18 17:58:15 -04:00
co-authored by Claude Opus 5
parent 4c671fbf3a
commit 6d63c34b2c
2 changed files with 68 additions and 7 deletions
+59
View File
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MatrixClient, MatrixError } from 'matrix-js-sdk';
import { uploadContent } from './matrix';
// Drives `uploadContent` with a fake client whose uploads fail per `plan`
// (one entry per attempt: an Error to reject with, or 'ok'). Back-off sleeps
// are real timers, so failures are kept to 1-2 retries per test.
const run = async (plan: Array<Error | 'ok'>, cancelAfterAttempt?: number) => {
let attempt = 0;
const controller = new AbortController();
const mx = {
uploadContent: () => {
attempt += 1;
const step = plan[attempt - 1];
if (cancelAfterAttempt === attempt) controller.abort();
return step === 'ok' ? Promise.resolve({ content_uri: 'mxc://x/y' }) : Promise.reject(step);
},
} as unknown as MatrixClient;
let outcome: { mxc?: string; error?: MatrixError } = {};
await uploadContent(mx, new Blob(['x']) as unknown as File, {
onProgress: () => undefined,
onSuccess: (mxc) => {
outcome = { mxc };
},
onError: (error) => {
outcome = { error };
},
signal: controller.signal,
});
return { attempts: attempt, ...outcome };
};
const abortError = () => new DOMException('request failed', 'AbortError');
test('a dropped connection (SDK reports it as AbortError) is retried', async () => {
const r = await run([abortError(), 'ok']);
assert.equal(r.attempts, 2);
assert.equal(r.mxc, 'mxc://x/y');
});
test('a user cancel (our signal aborted) is NOT retried even though it is also an AbortError', async () => {
const r = await run([abortError(), 'ok'], 1);
assert.equal(r.attempts, 1);
assert.equal(r.mxc, undefined);
assert.ok(r.error);
});
test('5xx is retried, 4xx fails fast', async () => {
const server = new MatrixError({ errcode: 'M_UNKNOWN', error: 'boom' }, 502);
const r = await run([server, 'ok']);
assert.equal(r.attempts, 2);
assert.equal(r.mxc, 'mxc://x/y');
const tooLarge = new MatrixError({ errcode: 'M_TOO_LARGE', error: 'big' }, 413);
const s = await run([tooLarge, 'ok']);
assert.equal(s.attempts, 1);
assert.equal(s.error?.httpStatus, 413);
});