update downloadTool to handle errors from response stream and retry (#369)

This commit is contained in:
eric sciple
2020-03-05 12:05:27 -05:00
committed by GitHub
parent 6459481e98
commit 259743ae13
7 changed files with 320 additions and 51 deletions

View File

@@ -0,0 +1,55 @@
import * as core from '@actions/core'
/**
* Internal class for retries
*/
export class RetryHelper {
private maxAttempts: number
private minSeconds: number
private maxSeconds: number
constructor(maxAttempts: number, minSeconds: number, maxSeconds: number) {
if (maxAttempts < 1) {
throw new Error('max attempts should be greater than or equal to 1')
}
this.maxAttempts = maxAttempts
this.minSeconds = Math.floor(minSeconds)
this.maxSeconds = Math.floor(maxSeconds)
if (this.minSeconds > this.maxSeconds) {
throw new Error('min seconds should be less than or equal to max seconds')
}
}
async execute<T>(action: () => Promise<T>): Promise<T> {
let attempt = 1
while (attempt < this.maxAttempts) {
// Try
try {
return await action()
} catch (err) {
core.info(err.message)
}
// Sleep
const seconds = this.getSleepAmount()
core.info(`Waiting ${seconds} seconds before trying again`)
await this.sleep(seconds)
attempt++
}
// Last attempt
return await action()
}
private getSleepAmount(): number {
return (
Math.floor(Math.random() * (this.maxSeconds - this.minSeconds + 1)) +
this.minSeconds
)
}
private async sleep(seconds: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, seconds * 1000))
}
}

View File

@@ -5,10 +5,13 @@ import * as os from 'os'
import * as path from 'path'
import * as httpm from '@actions/http-client'
import * as semver from 'semver'
import * as stream from 'stream'
import * as util from 'util'
import uuidV4 from 'uuid/v4'
import {exec} from '@actions/exec/lib/exec'
import {ExecOptions} from '@actions/exec/lib/interfaces'
import {ok} from 'assert'
import {RetryHelper} from './retry-helper'
export class HTTPError extends Error {
constructor(readonly httpStatusCode: number | undefined) {
@@ -55,55 +58,68 @@ export async function downloadTool(
url: string,
dest?: string
): Promise<string> {
// Wrap in a promise so that we can resolve from within stream callbacks
return new Promise<string>(async (resolve, reject) => {
try {
const http = new httpm.HttpClient(userAgent, [], {
allowRetries: true,
maxRetries: 3
})
dest = dest || path.join(tempDirectory, uuidV4())
await io.mkdirP(path.dirname(dest))
core.debug(`Downloading ${url}`)
core.debug(`Downloading ${dest}`)
dest = dest || path.join(tempDirectory, uuidV4())
await io.mkdirP(path.dirname(dest))
core.debug(`Downloading ${url}`)
core.debug(`Destination ${dest}`)
if (fs.existsSync(dest)) {
throw new Error(`Destination file path ${dest} already exists`)
}
const maxAttempts = 3
const minSeconds = getGlobal<number>(
'TEST_DOWNLOAD_TOOL_RETRY_MIN_SECONDS',
10
)
const maxSeconds = getGlobal<number>(
'TEST_DOWNLOAD_TOOL_RETRY_MAX_SECONDS',
20
)
const retryHelper = new RetryHelper(maxAttempts, minSeconds, maxSeconds)
return await retryHelper.execute(
async () => await downloadToolAttempt(url, dest || '')
)
}
const response: httpm.HttpClientResponse = await http.get(url)
async function downloadToolAttempt(url: string, dest: string): Promise<string> {
if (fs.existsSync(dest)) {
throw new Error(`Destination file path ${dest} already exists`)
}
if (response.message.statusCode !== 200) {
const err = new HTTPError(response.message.statusCode)
core.debug(
`Failed to download from "${url}". Code(${response.message.statusCode}) Message(${response.message.statusMessage})`
)
throw err
}
const file: NodeJS.WritableStream = fs.createWriteStream(dest)
file.on('open', async () => {
try {
const stream = response.message.pipe(file)
stream.on('close', () => {
core.debug('download complete')
resolve(dest)
})
} catch (err) {
core.debug(
`Failed to download from "${url}". Code(${response.message.statusCode}) Message(${response.message.statusMessage})`
)
reject(err)
}
})
file.on('error', err => {
file.end()
reject(err)
})
} catch (err) {
reject(err)
}
// Get the response headers
const http = new httpm.HttpClient(userAgent, [], {
allowRetries: false
})
const response: httpm.HttpClientResponse = await http.get(url)
if (response.message.statusCode !== 200) {
const err = new HTTPError(response.message.statusCode)
core.debug(
`Failed to download from "${url}". Code(${response.message.statusCode}) Message(${response.message.statusMessage})`
)
throw err
}
// Download the response body
const pipeline = util.promisify(stream.pipeline)
const responseMessageFactory = getGlobal<() => stream.Readable>(
'TEST_DOWNLOAD_TOOL_RESPONSE_MESSAGE_FACTORY',
() => response.message
)
const readStream = responseMessageFactory()
let succeeded = false
try {
await pipeline(readStream, fs.createWriteStream(dest))
core.debug('download complete')
succeeded = true
return dest
} finally {
// Error, delete dest before retry
if (!succeeded) {
core.debug('download failed')
try {
await io.rmRF(dest)
} catch (err) {
core.debug(`Failed to delete '${dest}'. ${err.message}`)
}
}
}
}
/**
@@ -516,3 +532,13 @@ function _evaluateVersions(versions: string[], versionSpec: string): string {
return version
}
/**
* Gets a global variable
*/
function getGlobal<T>(key: string, defaultValue: T): T {
/* eslint-disable @typescript-eslint/no-explicit-any */
const value = (global as any)[key] as T | undefined
/* eslint-enable @typescript-eslint/no-explicit-any */
return value !== undefined ? value : defaultValue
}