Skip to content
Merged
4 changes: 2 additions & 2 deletions packages/artifact/__tests__/download.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ describe('Download Tests', () => {
setupFailedResponse()
const downloadHttpClient = new DownloadHttpClient()
expect(downloadHttpClient.listArtifacts()).rejects.toThrow(
'Unable to list artifacts for the run'
'List Artifacts failed: Artifact service responded with 500'
)
})

Expand Down Expand Up @@ -113,7 +113,7 @@ describe('Download Tests', () => {
configVariables.getRuntimeUrl()
)
).rejects.toThrow(
`Unable to get ContainersItems from ${configVariables.getRuntimeUrl()}`
`Get Container Items failed: Artifact service responded with 500`
)
})

Expand Down
116 changes: 116 additions & 0 deletions packages/artifact/__tests__/retry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import {retry} from '../src/internal/requestUtils'
import * as core from '@actions/core'

interface ITestResponse {
statusCode: number
result: string | null
error: Error | null
}

function TestResponse(
action: number | Error,
result: string | null = null
): ITestResponse {
if (action instanceof Error) {
return {
statusCode: -1,
result,
error: action
}
} else {
return {
statusCode: action,
result,
error: null
}
}
}

async function handleResponse(
response: ITestResponse | undefined
): Promise<ITestResponse> {
if (!response) {
// eslint-disable-next-line no-undef
fail('Retry method called too many times')
}

if (response.error) {
throw response.error
} else {
return Promise.resolve(response)
}
}

async function testRetryExpectingResult(
responses: ITestResponse[],
expectedResult: string | null
): Promise<void> {
responses = responses.reverse() // Reverse responses since we pop from end

const actualResult = await retry(
'test',
async () => handleResponse(responses.pop()),
(response: ITestResponse) => response.statusCode,
new Map(), // extra error message for any particular http codes
2, // maxAttempts
0 // delay
)

expect(actualResult.result).toEqual(expectedResult)
}

async function testRetryExpectingError(
responses: ITestResponse[]
): Promise<void> {
responses = responses.reverse() // Reverse responses since we pop from end

expect(
retry(
'test',
async () => handleResponse(responses.pop()),
(response: ITestResponse) => response.statusCode,
new Map(), // extra error message for any particular http codes
2, // maxAttempts,
0 // delay
)
).rejects.toBeInstanceOf(Error)
}

beforeAll(async () => {
// mock all output so that there is less noise when running tests
jest.spyOn(console, 'log').mockImplementation(() => {})
jest.spyOn(core, 'debug').mockImplementation(() => {})
jest.spyOn(core, 'info').mockImplementation(() => {})
jest.spyOn(core, 'warning').mockImplementation(() => {})
jest.spyOn(core, 'error').mockImplementation(() => {})
})

test('retry works on successful response', async () => {
await testRetryExpectingResult([TestResponse(200, 'Ok')], 'Ok')
})

test('retry works after retryable status code', async () => {
await testRetryExpectingResult(
[TestResponse(503), TestResponse(200, 'Ok')],
'Ok'
)
})

test('retry fails after exhausting retries', async () => {
await testRetryExpectingError([
TestResponse(503),
TestResponse(503),
TestResponse(200, 'Ok')
])
})

test('retry fails after non-retryable status code', async () => {
await testRetryExpectingError([TestResponse(500), TestResponse(200, 'Ok')])
})

test('retry works after error', async () => {
await testRetryExpectingResult(
[TestResponse(new Error('Test error')), TestResponse(200, 'Ok')],
'Ok'
)
})
8 changes: 5 additions & 3 deletions packages/artifact/__tests__/upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ describe('Upload Tests', () => {
uploadHttpClient.createArtifactInFileContainer(artifactName)
).rejects.toEqual(
new Error(
`Unable to create a container for the artifact invalid-artifact-name at ${getArtifactUrl()}`
`Create Artifact Container failed: Artifact service responded with 400 : The artifact name invalid-artifact-name is not valid. Request URL ${getArtifactUrl()}`
Comment thread
konradpabjan marked this conversation as resolved.
Outdated
)
)
})
Expand All @@ -125,7 +125,7 @@ describe('Upload Tests', () => {
uploadHttpClient.createArtifactInFileContainer(artifactName)
).rejects.toEqual(
new Error(
'Artifact storage quota has been hit. Unable to upload any new artifacts'
'Create Artifact Container failed: Artifact service responded with 403 : Artifact storage quota has been hit. Unable to upload any new artifacts'
)
)
})
Expand Down Expand Up @@ -362,7 +362,9 @@ describe('Upload Tests', () => {
const uploadHttpClient = new UploadHttpClient()
expect(
uploadHttpClient.patchArtifactSize(-2, 'my-artifact')
).rejects.toThrow('Unable to finish uploading artifact my-artifact')
).rejects.toThrow(
'Patch Artifact Size failed: Artifact service responded with 400'
Comment thread
konradpabjan marked this conversation as resolved.
Outdated
)
})

/**
Expand Down
33 changes: 14 additions & 19 deletions packages/artifact/src/internal/download-http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
tryGetRetryAfterValueTimeInMilliseconds,
displayHttpDiagnostics,
getFileSize,
rmFile
rmFile,
sleep
} from './utils'
import {URL} from 'url'
import {StatusReporter} from './status-reporter'
Expand All @@ -22,6 +23,7 @@ import {HttpManager} from './http-manager'
import {DownloadItem} from './download-specification'
import {getDownloadFileConcurrency, getRetryLimit} from './config-variables'
import {IncomingHttpHeaders} from 'http'
import {retryHttpClientResponse} from './requestUtils'

export class DownloadHttpClient {
// http manager is used for concurrent connections when downloading multiple files at once
Expand All @@ -46,16 +48,11 @@ export class DownloadHttpClient {
// use the first client from the httpManager, `keep-alive` is not used so the connection will close immediately
const client = this.downloadHttpManager.getClient(0)
const headers = getDownloadHeaders('application/json')
const response = await client.get(artifactUrl, headers)
const body: string = await response.readBody()

if (isSuccessStatusCode(response.message.statusCode) && body) {
return JSON.parse(body)
}
displayHttpDiagnostics(response)
throw new Error(
`Unable to list artifacts for the run. Resource Url ${artifactUrl}`
const response = await retryHttpClientResponse('List Artifacts', async () =>
client.get(artifactUrl, headers)
)
const body: string = await response.readBody()
return JSON.parse(body)
}

/**
Expand All @@ -74,14 +71,12 @@ export class DownloadHttpClient {
// use the first client from the httpManager, `keep-alive` is not used so the connection will close immediately
const client = this.downloadHttpManager.getClient(0)
const headers = getDownloadHeaders('application/json')
const response = await client.get(resourceUrl.toString(), headers)
const response = await retryHttpClientResponse(
'Get Container Items',
async () => client.get(resourceUrl.toString(), headers)
)
const body: string = await response.readBody()

if (isSuccessStatusCode(response.message.statusCode) && body) {
return JSON.parse(body)
}
displayHttpDiagnostics(response)
throw new Error(`Unable to get ContainersItems from ${resourceUrl}`)
return JSON.parse(body)
}

/**
Expand Down Expand Up @@ -188,14 +183,14 @@ export class DownloadHttpClient {
core.info(
`Backoff due to too many requests, retry #${retryCount}. Waiting for ${retryAfterValue} milliseconds before continuing the download`
)
await new Promise(resolve => setTimeout(resolve, retryAfterValue))
await sleep(retryAfterValue)
} else {
// Back off using an exponential value that depends on the retry count
const backoffTime = getExponentialRetryTimeInMilliseconds(retryCount)
core.info(
`Exponential backoff for retry #${retryCount}. Waiting for ${backoffTime} milliseconds before continuing the download`
)
await new Promise(resolve => setTimeout(resolve, backoffTime))
await sleep(backoffTime)
}
core.info(
`Finished backoff for retry #${retryCount}, continuing with download`
Expand Down
74 changes: 74 additions & 0 deletions packages/artifact/src/internal/requestUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {IHttpClientResponse} from '@actions/http-client/interfaces'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I saw https://github.com/actions/toolkit/blob/main/packages/cache/src/internal/requestUtils.ts which you linked. Would we be able to share the same basic retry logic?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @dhadka and @joshmgross to see if we can share code with the cache action

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we'd need to move the shared code to it's own package, or add this all to https://github.com/actions/http-client

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A separate (small) NPM package or http-client would make sense. This feels like a long term investment though

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, if it needs a separate package then I agree we can hold off. We've tried sharing files among packages before in Azure Pipelines and it doesn't work well.

I think it could fit in in @actions/io, but that would mean making it a part of the public interface there.

import {isRetryableStatusCode, isSuccessStatusCode, sleep} from './utils'
import * as core from '@actions/core'

export async function retry<T>(
name: string,
method: () => Promise<T>,
Comment thread
konradpabjan marked this conversation as resolved.
Outdated
getStatusCode: (response: T) => number | undefined,
Comment thread
konradpabjan marked this conversation as resolved.
Outdated
errorMessages: Map<number, string>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be more flexible just to pass in:

Suggested change
errorMessages: Map<number, string>,
getErrorMessage: (response: T) => string,

Some API responses might have an error message in the body.

@konradpabjan konradpabjan Dec 17, 2020

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My original intention with this errorMessages map was so that certain http calls can produce slightly different exception messages if certain response codes are encountered. For example, during artifact creation we might get a 400 which is indicative of a invalid artifact name. However during other calls a 400 might be something totally different so this map helps the method caller customize that users will ultimately see in the logs if something goes wrong.

maxAttempts: number,
delay: number
Comment thread
konradpabjan marked this conversation as resolved.
Outdated
): Promise<T> {
let response: T | undefined = undefined
let statusCode: number | undefined = undefined
let isRetryable = false
let errorMessage = ''
let extraErrorInformation: string | undefined = undefined
let attempt = 1

while (attempt <= maxAttempts) {
try {
response = await method()
statusCode = getStatusCode(response)

if (isSuccessStatusCode(statusCode)) {
return response
}

// Extra error information that we want to display if a particular response code is hit
if (statusCode) {
extraErrorInformation = errorMessages.get(statusCode)
}

isRetryable = isRetryableStatusCode(statusCode)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can take this a step further to separate this into an HTTP layer and an HTTP-agnostic retry function:

  • Pull out getStatusCode, isSuccessStatusCode, isRetryableStatusCode from the retry function and replace it with a single function you pass in called shouldRetry
  • retryHttpClientResponse then can take the HTTP-related stuff and reduce it to a shouldRetry callback

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was mostly going off of the existing retry function that cache uses. I feel like pulling out isSuccessStatusCode and getStatusCode could cause some repetition throughout the rest of the code. Overall I think the current pattern is sufficient for the calls that we need to make.

@brcrista brcrista Dec 17, 2020

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It shouldn't cause any repetition because retryHttpClientRequest will shim it. It might work but I don't think it's as clear as it could be -- separation of concerns will help with that, and make testing easier. I'd invoke the principle of "code is written once but read many times" here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spent a bit of time trying to pull out each of the methods, but I could really get it in a nice enough format without breaking too much and I don't think it works all that well. The current getStatusCode method is actually structured the way it is so that it's easier to test each of the responses:

(response: ITestResponse) => response.statusCode,

My original intention was to use mostly what cache had without changing it too much so that if there is a fix in one package the two are relatively the same and it's easy to follow.

errorMessage = `Artifact service responded with ${statusCode}`
} catch (error) {
isRetryable = true
errorMessage = error.message
}

if (!isRetryable) {
core.info(`${name} - Error is not retryable`)
break
}

core.info(
`${name} - Attempt ${attempt} of ${maxAttempts} failed with error: ${errorMessage}`
)

await sleep(delay)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we increase the delay on each attempt? I think we should also check delay > 0

@konradpabjan konradpabjan Dec 17, 2020

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, the existing methods that are retryable (the PUT calls during upload and GET calls during download, see PR description) have their own retryablity which includes exponential back-off after first checking for a retry-after header that we might send back if too many requests are being made. You can see it here:

const backOff = async (retryAfterValue?: number): Promise<void> => {
this.uploadHttpManager.disposeAndReplaceClient(httpClientIndex)
if (retryAfterValue) {
core.info(
`Backoff due to too many requests, retry #${retryCount}. Waiting for ${retryAfterValue} milliseconds before continuing the upload`
)
await new Promise(resolve => setTimeout(resolve, retryAfterValue))
} else {
const backoffTime = getExponentialRetryTimeInMilliseconds(retryCount)
core.info(
`Exponential backoff for retry #${retryCount}. Waiting for ${backoffTime} milliseconds before continuing the upload at offset ${start}`
)
await new Promise(resolve => setTimeout(resolve, backoffTime))
}
core.info(
`Finished backoff for retry #${retryCount}, continuing with upload`
)
return
}

What I did in this PR, is I moved the sleep method to a shared util file. The existing methods that are retried are a little bit more complicated because there are multiple calls being done concurrently so they're not switching over to the new retryHttpClientRequest method in this PR. What I am planning on doing in a follow-up PR is:

  • Move checking for the retry-after header into util alongside computing the exponential back-off time there
  • Add exponential back-off to retryHttpClientRequest that is being added as part of this PR
  • Switch over all the HTTP calls to use the same retryHttpClientRequest method and clean up the code so there aren't 2 separate retry mechanisms

The last part would require a lot more refactoring and I don't want to make this PR too big so I want to do things in phases. In the past, large updates to the artifact actions would take a while to get through and they were harder to test so I want to split things up into manageable chunks

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regardless of what the other code is doing, I think we should still implement exponential backoff in the new code. The reason we use exponential backoff is that any fixed delay time we choose might be too short and put too much pressure on the service.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added! Most of the code is already there, so it was pretty simple

/**
* Returns a retry time in milliseconds that exponentially gets larger
* depending on the amount of retries that have been attempted
*/
export function getExponentialRetryTimeInMilliseconds(
retryCount: number
): number {
if (retryCount < 0) {
throw new Error('RetryCount should not be negative')
} else if (retryCount === 0) {
return getInitialRetryIntervalInMilliseconds()
}
const minTime =
getInitialRetryIntervalInMilliseconds() * getRetryMultiplier() * retryCount
const maxTime = minTime * getRetryMultiplier()
// returns a random number between the minTime (inclusive) and the maxTime (exclusive)
return Math.random() * (maxTime - minTime) + minTime
}

attempt++
}

if (extraErrorInformation) {
throw Error(`${name} failed: ${errorMessage} : ${extraErrorInformation}`)
}
throw Error(`${name} failed: ${errorMessage}`)
}

export async function retryHttpClientResponse<T>(
Comment thread
konradpabjan marked this conversation as resolved.
Outdated
name: string,
method: () => Promise<IHttpClientResponse>,
errorMessages: Map<number, string> = new Map(),
maxAttempts = 3
): Promise<IHttpClientResponse> {
return await retry(
name,
method,
(response: IHttpClientResponse) => response.message.statusCode,
errorMessages,
maxAttempts,
5000
)
}
Loading