Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/artifact/RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,7 @@

- Improved retry-ability when a partial artifact download is encountered

### 0.5.0

- Improved retry-ability for all http calls during artifact upload and download if an error is encountered

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
114 changes: 114 additions & 0 deletions packages/artifact/__tests__/retry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import * as http from 'http'
import * as net from 'net'
import * as core from '@actions/core'
import * as configVariables from '../src/internal/config-variables'
import {retry} from '../src/internal/requestUtils'
import {IHttpClientResponse} from '@actions/http-client/interfaces'
import {HttpClientResponse} from '@actions/http-client'

jest.mock('../src/internal/config-variables')

interface ITestResult {
responseCode: number
errorMessage: string | null
}

async function testRetry(
responseCodes: number[],
expectedResult: ITestResult
): Promise<void> {
const reverse = responseCodes.reverse() // Reverse responses since we pop from end
if (expectedResult.errorMessage) {
// we expect some exception to be thrown
expect(
retry(
'test',
async () => handleResponse(reverse.pop()),
new Map(), // extra error message for any particular http codes
configVariables.getRetryLimit()
)
).rejects.toThrow(expectedResult.errorMessage)
} else {
// we expect a correct status code to be returned
const actualResult = await retry(
'test',
async () => handleResponse(reverse.pop()),
new Map(), // extra error message for any particular http codes
configVariables.getRetryLimit()
)
expect(actualResult.message.statusCode).toEqual(expectedResult.responseCode)
}
}

async function handleResponse(
testResponseCode: number | undefined
): Promise<IHttpClientResponse> {
if (!testResponseCode) {
throw new Error(
'Test incorrectly set up. reverse.pop() was called too many times so not enough test response codes were supplied'
)
}

return setupSingleMockResponse(testResponseCode)
}

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(() => {})
})

/**
* Helpers used to setup mocking for the HttpClient
*/
async function emptyMockReadBody(): Promise<string> {
return new Promise(resolve => {
resolve()
})
}

async function setupSingleMockResponse(
statusCode: number
): Promise<IHttpClientResponse> {
const mockMessage = new http.IncomingMessage(new net.Socket())
const mockReadBody = emptyMockReadBody
mockMessage.statusCode = statusCode
return new Promise<HttpClientResponse>(resolve => {
resolve({
message: mockMessage,
readBody: mockReadBody
})
})
}

test('retry works on successful response', async () => {
await testRetry([200], {
responseCode: 200,
errorMessage: null
})
})

test('retry works after retryable status code', async () => {
await testRetry([503, 200], {
responseCode: 200,
errorMessage: null
})
})

test('retry fails after exhausting retries', async () => {
// __mocks__/config-variables caps the max retry count in tests to 2
await testRetry([503, 503, 200], {
responseCode: 200,
errorMessage: 'test failed: Artifact service responded with 503'
})
})

test('retry fails after non-retryable status code', async () => {
await testRetry([500, 200], {
responseCode: 500,
errorMessage: 'test failed: Artifact service responded with 500'
})
})
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: The artifact name invalid-artifact-name is not valid. Request URL ${getArtifactUrl()}`
)
)
})
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 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(
'Finalize artifact upload failed: Artifact service responded with 400'
)
})

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/artifact/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/artifact/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@actions/artifact",
"version": "0.4.2",
"version": "0.5.0",
"preview": true,
"description": "Actions artifact lib",
"keywords": [
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 {retryHttpClientRequest} 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 retryHttpClientRequest('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 retryHttpClientRequest(
'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
79 changes: 79 additions & 0 deletions packages/artifact/src/internal/requestUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {IHttpClientResponse} from '@actions/http-client/interfaces'
Copy link
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
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
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
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
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,
getExponentialRetryTimeInMilliseconds,
displayHttpDiagnostics
} from './utils'
import * as core from '@actions/core'
import {getRetryLimit} from './config-variables'

export async function retry(
name: string,
operation: () => Promise<IHttpClientResponse>,
customErrorMessages: Map<number, string>,
maxAttempts: number
): Promise<IHttpClientResponse> {
let response: IHttpClientResponse | undefined = undefined
let statusCode: number | undefined = undefined
let isRetryable = false
let errorMessage = ''
let customErrorInformation: string | undefined = undefined
let attempt = 1

while (attempt <= maxAttempts) {
try {
response = await operation()
statusCode = response.message.statusCode

if (isSuccessStatusCode(statusCode)) {
return response
}

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

isRetryable = isRetryableStatusCode(statusCode)
Copy link
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
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.

Copy link
Contributor

@brcrista brcrista Dec 17, 2020

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
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`)
if (response) {
displayHttpDiagnostics(response)
}
break
}

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

await sleep(getExponentialRetryTimeInMilliseconds(attempt))
attempt++
}

if (response) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we want to move this closer to the actual call, so we can log diagnostic info of every operation. Otherwise LGTM.

Copy link
Contributor

Choose a reason for hiding this comment

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

Line 27 in this same file. We should just log it there.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm going to leave it as is. This one method gets called only if we exhaust all retries so we only display the full diagnostics info in two scenarios:

  • we have exhausted all retries
  • a non-retryable status code was hit

Only if the call actually fails we display the full diagnostics. In all other cases I don't think it's sufficient as the response code will be displayed

displayHttpDiagnostics(response)
}

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

export async function retryHttpClientRequest<T>(
name: string,
method: () => Promise<IHttpClientResponse>,
customErrorMessages: Map<number, string> = new Map(),
maxAttempts = getRetryLimit()
): Promise<IHttpClientResponse> {
return await retry(name, method, customErrorMessages, maxAttempts)
}
Loading