Merge pull request #4138 from github/henrymercer/bundle-download-errors

Preserve HTTP errors from streaming bundle downloads
This commit is contained in:
Henry Mercer
2026-09-15 12:36:14 +00:00
committed by GitHub
3 changed files with 173 additions and 17 deletions

16
lib/entry-points.js generated
View File

@@ -151690,11 +151690,14 @@ async function downloadAndExtract(codeqlURL, compressionMethod, dest, authorizat
return { totalDurationMs }; return { totalDurationMs };
} }
} catch (e) { } catch (e) {
await cleanUpPath(dest, "CodeQL bundle", logger);
if (asHTTPError(e)?.status === 404) {
throw e;
}
core11.warning( core11.warning(
`Failed to download and extract CodeQL bundle using streaming with error: ${getErrorMessage(e)}` `Failed to download and extract CodeQL bundle using streaming with error: ${getErrorMessage(e)}`
); );
core11.warning(`Falling back to downloading the bundle before extracting.`); core11.warning(`Falling back to downloading the bundle before extracting.`);
await cleanUpPath(dest, "CodeQL bundle", logger);
} }
const toolsDownloadStart = import_perf_hooks2.performance.now(); const toolsDownloadStart = import_perf_hooks2.performance.now();
const archivedBundlePath = await toolcache2.downloadTool( const archivedBundlePath = await toolcache2.downloadTool(
@@ -151766,9 +151769,14 @@ async function downloadAndExtractZstdWithStreaming(codeqlURL, dest, authorizatio
}); });
if (response.statusCode !== 200) { if (response.statusCode !== 200) {
response.resume(); response.resume();
throw new Error( const baseMessage = `Failed to download CodeQL bundle from ${codeqlURL}.`;
`Failed to download CodeQL bundle from ${codeqlURL}. HTTP status code: ${response.statusCode}.` if (response.statusCode !== void 0) {
); throw new HTTPError(
`${baseMessage} HTTP status code: ${response.statusCode}.`,
response.statusCode
);
}
throw new Error(baseMessage);
} }
await extractTarZst(response, dest, tarVersion, logger); await extractTarZst(response, dest, tarVersion, logger);
} }

View File

@@ -1,8 +1,12 @@
import { once } from "events"; import { once } from "events";
import * as fs from "fs";
import { ClientRequest, IncomingMessage } from "http";
import * as path from "path"; import * as path from "path";
import * as core from "@actions/core";
import * as toolcache from "@actions/tool-cache"; import * as toolcache from "@actions/tool-cache";
import test from "ava"; import test from "ava";
import { https } from "follow-redirects";
import nock from "nock"; import nock from "nock";
import * as sinon from "sinon"; import * as sinon from "sinon";
@@ -10,14 +14,14 @@ import { getRunnerLogger } from "./logging";
import * as tar from "./tar"; import * as tar from "./tar";
import { setupTests } from "./testing-utils"; import { setupTests } from "./testing-utils";
import { downloadAndExtract } from "./tools-download"; import { downloadAndExtract } from "./tools-download";
import { withTmpDir } from "./util"; import * as util from "./util";
setupTests(test); setupTests(test);
test.serial( test.serial(
"downloadAndExtract reports the durations when downloading before extracting", "downloadAndExtract reports the durations when downloading before extracting",
async (t) => { async (t) => {
await withTmpDir(async (tmpDir) => { await util.withTmpDir(async (tmpDir) => {
const archivePath = path.join(tmpDir, "codeql-bundle.tar.gz"); const archivePath = path.join(tmpDir, "codeql-bundle.tar.gz");
const destination = path.join(tmpDir, "codeql"); const destination = path.join(tmpDir, "codeql");
sinon.stub(toolcache, "downloadTool").resolves(archivePath); sinon.stub(toolcache, "downloadTool").resolves(archivePath);
@@ -43,13 +47,16 @@ test.serial(
test.serial( test.serial(
"downloadAndExtract falls back to downloading before extracting if streaming fails", "downloadAndExtract falls back to downloading before extracting if streaming fails",
async (t) => { async (t) => {
await withTmpDir(async (tmpDir) => { await util.withTmpDir(async (tmpDir) => {
sinon.stub(process, "platform").value("linux"); sinon.stub(process, "platform").value("linux");
const archivePath = path.join(tmpDir, "codeql-bundle.tar.zst"); const archivePath = path.join(tmpDir, "codeql-bundle.tar.zst");
const destination = path.join(tmpDir, "codeql"); const destination = path.join(tmpDir, "codeql");
const downloadTool = sinon const downloadTool = sinon
.stub(toolcache, "downloadTool") .stub(toolcache, "downloadTool")
.resolves(archivePath); .callsFake(async () => {
t.false(fs.existsSync(destination));
return archivePath;
});
const extract = sinon.stub(tar, "extract").resolves(destination); const extract = sinon.stub(tar, "extract").resolves(destination);
const extractTarZst = sinon.stub(tar, "extractTarZst").resolves(); const extractTarZst = sinon.stub(tar, "extractTarZst").resolves();
const request = nock("https://example.com") const request = nock("https://example.com")
@@ -78,10 +85,134 @@ test.serial(
}, },
); );
test.serial(
"downloadAndExtract rethrows a 404 rather than retrying the download",
async (t) => {
await util.withTmpDir(async (tmpDir) => {
sinon.stub(process, "platform").value("linux");
const destination = path.join(tmpDir, "codeql");
const downloadTool = sinon.stub(toolcache, "downloadTool");
const extractTarZst = sinon.stub(tar, "extractTarZst").resolves();
const request = nock("https://example.com")
.get("/codeql-bundle.tar.zst")
.reply(404, "Not found");
const error = await t.throwsAsync(
downloadAndExtract(
"https://example.com/codeql-bundle.tar.zst",
"zstd",
destination,
undefined,
{},
{ type: "gnu", version: "1.34" },
getRunnerLogger(true),
),
{
instanceOf: util.HTTPError,
message:
"Failed to download CodeQL bundle from https://example.com/codeql-bundle.tar.zst. HTTP status code: 404.",
},
);
t.is(error?.status, 404);
t.true(request.isDone());
t.false(extractTarZst.called);
t.false(downloadTool.called);
t.false(fs.existsSync(destination));
});
},
);
test.serial(
"downloadAndExtract falls back to downloading before extracting on a server error",
async (t) => {
await util.withTmpDir(async (tmpDir) => {
sinon.stub(process, "platform").value("linux");
const archivePath = path.join(tmpDir, "codeql-bundle.tar.zst");
const destination = path.join(tmpDir, "codeql");
const downloadTool = sinon
.stub(toolcache, "downloadTool")
.callsFake(async () => {
t.false(fs.existsSync(destination));
return archivePath;
});
const extract = sinon.stub(tar, "extract").resolves(destination);
const extractTarZst = sinon.stub(tar, "extractTarZst").resolves();
const request = nock("https://example.com")
.get("/codeql-bundle.tar.zst")
.reply(500);
const statusReport = await downloadAndExtract(
"https://example.com/codeql-bundle.tar.zst",
"zstd",
destination,
undefined,
{},
{ type: "gnu", version: "1.34" },
getRunnerLogger(true),
);
t.assert(Number.isInteger(statusReport.downloadDurationMs));
t.true(request.isDone());
t.false(extractTarZst.called);
t.true(downloadTool.calledOnce);
t.true(extract.calledOnce);
});
},
);
test.serial(
"downloadAndExtract handles an unknown status as a non-HTTP error",
async (t) => {
const asHTTPError = sinon.spy(util, "asHTTPError");
await util.withTmpDir(async (tmpDir) => {
sinon.stub(process, "platform").value("linux");
const archivePath = path.join(tmpDir, "codeql-bundle.tar.zst");
const destination = path.join(tmpDir, "codeql");
const response = sinon.createStubInstance(IncomingMessage);
response.statusCode = undefined;
sinon
.stub(https, "get")
.callsArgWith(2, response)
.returns(sinon.createStubInstance(ClientRequest));
const warning = sinon.stub(core, "warning");
const downloadTool = sinon
.stub(toolcache, "downloadTool")
.resolves(archivePath);
const extract = sinon.stub(tar, "extract").resolves(destination);
const extractTarZst = sinon.stub(tar, "extractTarZst").resolves();
await downloadAndExtract(
"https://example.com/codeql-bundle.tar.zst",
"zstd",
destination,
undefined,
{},
{ type: "gnu", version: "1.34" },
getRunnerLogger(true),
);
t.is(
warning.firstCall.args[0],
"Failed to download and extract CodeQL bundle using streaming with error: Failed to download CodeQL bundle from https://example.com/codeql-bundle.tar.zst.",
);
t.true(response.resume.calledOnce);
t.false(extractTarZst.called);
t.true(downloadTool.calledOnce);
t.true(extract.calledOnce);
});
t.true(asHTTPError.calledOnce);
t.true(asHTTPError.firstCall.args[0] instanceof Error);
t.false(asHTTPError.firstCall.args[0] instanceof util.HTTPError);
t.is(asHTTPError.firstCall.returnValue, undefined);
},
);
test.serial( test.serial(
"downloadAndExtract reports only the total duration when streaming extraction", "downloadAndExtract reports only the total duration when streaming extraction",
async (t) => { async (t) => {
await withTmpDir(async (tmpDir) => { await util.withTmpDir(async (tmpDir) => {
sinon.stub(process, "platform").value("linux"); sinon.stub(process, "platform").value("linux");
const downloadTool = sinon.stub(toolcache, "downloadTool"); const downloadTool = sinon.stub(toolcache, "downloadTool");
const extractTarZst = sinon const extractTarZst = sinon

View File

@@ -14,7 +14,13 @@ import { ActionState } from "./action-common";
import { ActionsEnvVars, getEnv, ReadOnlyEnv } from "./environment"; import { ActionsEnvVars, getEnv, ReadOnlyEnv } from "./environment";
import { formatDuration, Logger } from "./logging"; import { formatDuration, Logger } from "./logging";
import * as tar from "./tar"; import * as tar from "./tar";
import { cleanUpPath, getErrorMessage, getRequiredEnvParam } from "./util"; import {
asHTTPError,
cleanUpPath,
getErrorMessage,
getRequiredEnvParam,
HTTPError,
} from "./util";
/** /**
* High watermark to use when streaming the download and extraction of the CodeQL tools. * High watermark to use when streaming the download and extraction of the CodeQL tools.
@@ -88,14 +94,20 @@ export async function downloadAndExtract(
return { totalDurationMs }; return { totalDurationMs };
} }
} catch (e) { } catch (e) {
// If we failed during processing, we want to clean up the destination directory
// before we either try again or give up.
await cleanUpPath(dest, "CodeQL bundle", logger);
// Retrying a 404 is pointless: the asset does not exist, so downloading it a different way
// will fail in the same way.
if (asHTTPError(e)?.status === 404) {
throw e;
}
core.warning( core.warning(
`Failed to download and extract CodeQL bundle using streaming with error: ${getErrorMessage(e)}`, `Failed to download and extract CodeQL bundle using streaming with error: ${getErrorMessage(e)}`,
); );
core.warning(`Falling back to downloading the bundle before extracting.`); core.warning(`Falling back to downloading the bundle before extracting.`);
// If we failed during processing, we want to clean up the destination directory
// before we try again.
await cleanUpPath(dest, "CodeQL bundle", logger);
} }
const toolsDownloadStart = performance.now(); const toolsDownloadStart = performance.now();
@@ -191,9 +203,14 @@ async function downloadAndExtractZstdWithStreaming(
if (response.statusCode !== 200) { if (response.statusCode !== 200) {
// Discard the response body so that the connection can be released. // Discard the response body so that the connection can be released.
response.resume(); response.resume();
throw new Error( const baseMessage = `Failed to download CodeQL bundle from ${codeqlURL}.`;
`Failed to download CodeQL bundle from ${codeqlURL}. HTTP status code: ${response.statusCode}.`, if (response.statusCode !== undefined) {
); throw new HTTPError(
`${baseMessage} HTTP status code: ${response.statusCode}.`,
response.statusCode,
);
}
throw new Error(baseMessage);
} }
await tar.extractTarZst(response, dest, tarVersion, logger); await tar.extractTarZst(response, dest, tarVersion, logger);