Skip to content

Commit 5b54e17

Browse files
committed
Retry stalled SwiftPM artifact downloads
1 parent f1f516e commit 5b54e17

2 files changed

Lines changed: 167 additions & 8 deletions

File tree

‎packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
'use strict';
1212

1313
const {
14+
download,
1415
exists,
1516
extractXCFramework,
1617
findFirst,
@@ -545,6 +546,107 @@ describe('exists', () => {
545546
});
546547
});
547548

549+
// ---------------------------------------------------------------------------
550+
// download — retries transient transport failures without masking client errors.
551+
// ---------------------------------------------------------------------------
552+
553+
describe('download', () => {
554+
let originalFetch;
555+
let tempDir;
556+
557+
beforeEach(() => {
558+
originalFetch = globalThis.fetch;
559+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-download-'));
560+
});
561+
562+
afterEach(() => {
563+
globalThis.fetch = originalFetch;
564+
fs.rmSync(tempDir, {recursive: true, force: true});
565+
});
566+
567+
function downloadResponse(body, error, stall = false) {
568+
const bytes = Buffer.from(body);
569+
return {
570+
ok: true,
571+
status: 200,
572+
statusText: 'OK',
573+
headers: {
574+
get: name =>
575+
name.toLowerCase() === 'content-length' ? String(bytes.length) : null,
576+
},
577+
body: new ReadableStream({
578+
start(controller) {
579+
controller.enqueue(bytes);
580+
if (error != null) {
581+
controller.error(error);
582+
} else if (!stall) {
583+
controller.close();
584+
}
585+
},
586+
}),
587+
};
588+
}
589+
590+
it('retries a transient stream failure and replaces the partial download', async () => {
591+
globalThis.fetch = jest
592+
.fn()
593+
.mockResolvedValueOnce(
594+
downloadResponse('partial', new Error('terminated')),
595+
)
596+
.mockResolvedValueOnce(downloadResponse('complete'));
597+
const destination = path.join(tempDir, 'artifact.tar.gz');
598+
599+
await download('https://example/artifact.tar.gz', destination, () => {});
600+
601+
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
602+
expect(fs.readFileSync(destination, 'utf8')).toBe('complete');
603+
expect(fs.existsSync(`${destination}.download`)).toBe(false);
604+
});
605+
606+
it('retries when an otherwise healthy response stops making progress', async () => {
607+
jest.useFakeTimers();
608+
try {
609+
globalThis.fetch = jest
610+
.fn()
611+
.mockResolvedValueOnce(downloadResponse('partial', null, true))
612+
.mockResolvedValueOnce(downloadResponse('complete'));
613+
const destination = path.join(tempDir, 'stalled.tar.gz');
614+
615+
const pending = download(
616+
'https://example/stalled.tar.gz',
617+
destination,
618+
() => {},
619+
);
620+
await jest.advanceTimersByTimeAsync(61_500);
621+
await pending;
622+
623+
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
624+
expect(fs.readFileSync(destination, 'utf8')).toBe('complete');
625+
expect(fs.existsSync(`${destination}.download`)).toBe(false);
626+
} finally {
627+
jest.useRealTimers();
628+
}
629+
});
630+
631+
it('does not retry a deterministic HTTP client error', async () => {
632+
globalThis.fetch = jest.fn(async () => ({
633+
ok: false,
634+
status: 404,
635+
statusText: 'Not Found',
636+
headers: {get: () => null},
637+
body: null,
638+
}));
639+
const destination = path.join(tempDir, 'missing.tar.gz');
640+
641+
await expect(
642+
download('https://example/missing.tar.gz', destination, () => {}),
643+
).rejects.toThrow(/HTTP 404/);
644+
645+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
646+
expect(fs.existsSync(destination)).toBe(false);
647+
});
648+
});
649+
548650
// ---------------------------------------------------------------------------
549651
// resolveSnapshotUrl — parses maven-metadata.xml into a timestamped URL.
550652
// ---------------------------------------------------------------------------

‎packages/react-native/scripts/spm/download-spm-artifacts.js‎

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -640,16 +640,26 @@ function createProgressDisplay(
640640
type ProgressCallback = (label: string, downloaded: number, total: number, speed: number, done: boolean, elapsed: number) => void;
641641
*/
642642

643-
async function download(
643+
const DOWNLOAD_MAX_ATTEMPTS = 3;
644+
const DOWNLOAD_RETRY_BASE_DELAY_MS = 1000;
645+
const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000;
646+
647+
function isRetryableDownloadError(error /*: Error */) /*: boolean */ {
648+
// Client errors are deterministic except for request timeout / rate limiting.
649+
// Network and stream errors do not carry an HTTP prefix and are safe to retry.
650+
const httpStatus = /^HTTP (\d{3})\b/.exec(error.message)?.[1];
651+
if (httpStatus == null) {
652+
return true;
653+
}
654+
const status = Number(httpStatus);
655+
return status === 408 || status === 429 || status >= 500;
656+
}
657+
658+
async function downloadOnce(
644659
url /*: string */,
645660
destPath /*: string */,
646661
onProgress /*:: ?: ProgressCallback */,
647662
) /*: Promise<void> */ {
648-
if (fs.existsSync(destPath)) {
649-
log(` Already cached: ${path.basename(destPath)}`);
650-
return;
651-
}
652-
653663
const tmpPath = destPath + '.download';
654664
try {
655665
// $FlowFixMe[incompatible-call] global fetch not in Flow stubs
@@ -662,6 +672,7 @@ async function download(
662672
let downloadedBytes = 0;
663673
let lastPrintTime = Date.now();
664674
let lastPrintBytes = 0;
675+
let lastDataTime = Date.now();
665676
const startTime = Date.now();
666677

667678
const fileStream = fs.createWriteStream(tmpPath);
@@ -719,14 +730,25 @@ async function download(
719730
await new Promise((resolve, reject) => {
720731
let progressInterval;
721732
try {
722-
progressInterval = setInterval(() => reportProgress(), 500);
733+
progressInterval = setInterval(() => {
734+
reportProgress();
735+
if (Date.now() - lastDataTime >= DOWNLOAD_IDLE_TIMEOUT_MS) {
736+
nodeReadable.destroy(
737+
new Error(
738+
`Download made no progress for ${DOWNLOAD_IDLE_TIMEOUT_MS / 1000}s: ${url}`,
739+
),
740+
);
741+
}
742+
}, 500);
723743

724744
nodeReadable
725745
.on('data', chunk => {
726746
downloadedBytes += chunk.length;
747+
lastDataTime = Date.now();
727748
})
728749
.on('error', err => {
729750
clearInterval(progressInterval);
751+
fileStream.destroy();
730752
reject(err);
731753
})
732754
.pipe(fileStream)
@@ -737,10 +759,13 @@ async function download(
737759
})
738760
.on('error', err => {
739761
clearInterval(progressInterval);
762+
nodeReadable.destroy();
740763
reject(err);
741764
});
742765
} catch (err) {
743766
if (progressInterval != null) clearInterval(progressInterval);
767+
nodeReadable.destroy();
768+
fileStream.destroy();
744769
reject(err);
745770
}
746771
});
@@ -753,7 +778,7 @@ async function download(
753778

754779
fs.renameSync(tmpPath, destPath);
755780
} catch (err) {
756-
// Clean up partial .download temp file on failure
781+
// Clean up partial .download temp file on failure.
757782
try {
758783
fs.unlinkSync(tmpPath);
759784
} catch {
@@ -763,6 +788,37 @@ async function download(
763788
}
764789
}
765790

791+
async function download(
792+
url /*: string */,
793+
destPath /*: string */,
794+
onProgress /*:: ?: ProgressCallback */,
795+
) /*: Promise<void> */ {
796+
if (fs.existsSync(destPath)) {
797+
log(` Already cached: ${path.basename(destPath)}`);
798+
return;
799+
}
800+
801+
for (let attempt = 1; attempt <= DOWNLOAD_MAX_ATTEMPTS; attempt++) {
802+
try {
803+
await downloadOnce(url, destPath, onProgress);
804+
return;
805+
} catch (error) {
806+
if (
807+
attempt === DOWNLOAD_MAX_ATTEMPTS ||
808+
!isRetryableDownloadError(error)
809+
) {
810+
throw error;
811+
}
812+
const delayMs = DOWNLOAD_RETRY_BASE_DELAY_MS * attempt;
813+
warn(
814+
` Download failed (${error.message}); retrying in ${delayMs}ms ` +
815+
`(attempt ${attempt + 1}/${DOWNLOAD_MAX_ATTEMPTS})`,
816+
);
817+
await new Promise(resolve => setTimeout(resolve, delayMs));
818+
}
819+
}
820+
}
821+
766822
/**
767823
* Extracts a .tar.gz and returns the path to the first .xcframework found.
768824
*/
@@ -1544,6 +1600,7 @@ module.exports = {
15441600
resolveLatestV1Version,
15451601
resolveRNCoreArtifact,
15461602
resolveRNDepsArtifact,
1603+
download,
15471604
exists,
15481605
formatBytes,
15491606
formatSpeed,

0 commit comments

Comments
 (0)