forked from actions/toolkit
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Retry all http calls for artifact upload and download (actions#675)
* Retry all http calls for artifact upload and download * Extra debug information * Fix lint * Always read response body * PR Feedback * Change error message if patch call fails * Add exponential backoff when retrying * Rework tests and add diagnostic info if exception thrown * Fix lint * fix lint error for real this time * PR cleanup * 0.5.0 @actions/artifact release * Display diagnostic info if non-retryable code is hit
- Loading branch information
1 parent
9aefd17
commit fb864e7
Showing
10 changed files
with
272 additions
and
68 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
import {IHttpClientResponse} from '@actions/http-client/interfaces' | ||
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) | ||
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) { | ||
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) | ||
} |
Oops, something went wrong.