Files
cinny/src/app/utils/uploadRetry.test.ts
T
jaredandClaude Opus 5 6d63c34b2c
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
fix(upload): retry on dropped connections — the SDK reports XHR network failures as AbortError, which we treated as a user cancel (#172)
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
2026-09-18 17:58:15 -04:00

60 lines
2.1 KiB
TypeScript

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);
});