1
0
Fork 0

Merge pull request #558 from dhadka/dhadka/fix-error-handling

Handle errors representing non-successful http responses in retry logic
pull/615/head
David Hadka 2020-10-20 09:54:21 -05:00 committed by GitHub
commit f1b118b2a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 4339 additions and 88 deletions

View File

@ -21,3 +21,8 @@
### 1.0.2 ### 1.0.2
- Use posix archive format to add support for some tools - Use posix archive format to add support for some tools
### 1.0.3
- Use http-client v1.0.9
- Fixes error handling so retries are not attempted on non-retryable errors (409 Conflict, for example)
- Adds 5 second delay between retry attempts

View File

@ -1,27 +1,48 @@
import {retry} from '../src/internal/requestUtils' import {retry} from '../src/internal/requestUtils'
import {HttpClientError} from '@actions/http-client'
interface TestResponse { interface ITestResponse {
statusCode: number statusCode: number
result: string | null 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( async function handleResponse(
response: TestResponse | undefined response: ITestResponse | undefined
): Promise<TestResponse> { ): Promise<ITestResponse> {
if (!response) { if (!response) {
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
fail('Retry method called too many times') fail('Retry method called too many times')
} }
if (response.statusCode === 999) { if (response.error) {
throw Error('Test Error') throw response.error
} else { } else {
return Promise.resolve(response) return Promise.resolve(response)
} }
} }
async function testRetryExpectingResult( async function testRetryExpectingResult(
responses: TestResponse[], responses: ITestResponse[],
expectedResult: string | null expectedResult: string | null
): Promise<void> { ): Promise<void> {
responses = responses.reverse() // Reverse responses since we pop from end responses = responses.reverse() // Reverse responses since we pop from end
@ -29,14 +50,44 @@ async function testRetryExpectingResult(
const actualResult = await retry( const actualResult = await retry(
'test', 'test',
async () => handleResponse(responses.pop()), async () => handleResponse(responses.pop()),
(response: TestResponse) => response.statusCode (response: ITestResponse) => response.statusCode,
2, // maxAttempts
0 // delay
) )
expect(actualResult.result).toEqual(expectedResult) expect(actualResult.result).toEqual(expectedResult)
} }
async function testRetryConvertingErrorToResult(
responses: ITestResponse[],
expectedStatus: number,
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,
2, // maxAttempts
0, // delay
(e: Error) => {
if (e instanceof HttpClientError) {
return {
statusCode: e.statusCode,
result: null,
error: null
}
}
}
)
expect(actualResult.statusCode).toEqual(expectedStatus)
expect(actualResult.result).toEqual(expectedResult)
}
async function testRetryExpectingError( async function testRetryExpectingError(
responses: TestResponse[] responses: ITestResponse[]
): Promise<void> { ): Promise<void> {
responses = responses.reverse() // Reverse responses since we pop from end responses = responses.reverse() // Reverse responses since we pop from end
@ -44,97 +95,54 @@ async function testRetryExpectingError(
retry( retry(
'test', 'test',
async () => handleResponse(responses.pop()), async () => handleResponse(responses.pop()),
(response: TestResponse) => response.statusCode (response: ITestResponse) => response.statusCode,
2, // maxAttempts,
0 // delay
) )
).rejects.toBeInstanceOf(Error) ).rejects.toBeInstanceOf(Error)
} }
test('retry works on successful response', async () => { test('retry works on successful response', async () => {
await testRetryExpectingResult( await testRetryExpectingResult([TestResponse(200, 'Ok')], 'Ok')
[
{
statusCode: 200,
result: 'Ok'
}
],
'Ok'
)
}) })
test('retry works after retryable status code', async () => { test('retry works after retryable status code', async () => {
await testRetryExpectingResult( await testRetryExpectingResult(
[ [TestResponse(503), TestResponse(200, 'Ok')],
{
statusCode: 503,
result: null
},
{
statusCode: 200,
result: 'Ok'
}
],
'Ok' 'Ok'
) )
}) })
test('retry fails after exhausting retries', async () => { test('retry fails after exhausting retries', async () => {
await testRetryExpectingError([ await testRetryExpectingError([
{ TestResponse(503),
statusCode: 503, TestResponse(503),
result: null TestResponse(200, 'Ok')
},
{
statusCode: 503,
result: null
},
{
statusCode: 200,
result: 'Ok'
}
]) ])
}) })
test('retry fails after non-retryable status code', async () => { test('retry fails after non-retryable status code', async () => {
await testRetryExpectingError([ await testRetryExpectingError([TestResponse(500), TestResponse(200, 'Ok')])
{
statusCode: 500,
result: null
},
{
statusCode: 200,
result: 'Ok'
}
])
}) })
test('retry works after error', async () => { test('retry works after error', async () => {
await testRetryExpectingResult( await testRetryExpectingResult(
[ [TestResponse(new Error('Test error')), TestResponse(200, 'Ok')],
{
statusCode: 999,
result: null
},
{
statusCode: 200,
result: 'Ok'
}
],
'Ok' 'Ok'
) )
}) })
test('retry returns after client error', async () => { test('retry returns after client error', async () => {
await testRetryExpectingResult( await testRetryExpectingResult(
[ [TestResponse(400), TestResponse(200, 'Ok')],
{ null
statusCode: 400, )
result: null })
},
{ test('retry converts errors to response object', async () => {
statusCode: 200, await testRetryConvertingErrorToResult(
result: 'Ok' [TestResponse(new HttpClientError('Test error', 409))],
} 409,
],
null null
) )
}) })

4205
packages/cache/package-lock.json generated vendored

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "@actions/cache", "name": "@actions/cache",
"version": "1.0.2", "version": "1.0.3",
"preview": true, "preview": true,
"description": "Actions cache lib", "description": "Actions cache lib",
"keywords": [ "keywords": [
@ -40,7 +40,7 @@
"@actions/core": "^1.2.4", "@actions/core": "^1.2.4",
"@actions/exec": "^1.0.1", "@actions/exec": "^1.0.1",
"@actions/glob": "^0.1.0", "@actions/glob": "^0.1.0",
"@actions/http-client": "^1.0.8", "@actions/http-client": "^1.0.9",
"@actions/io": "^1.0.1", "@actions/io": "^1.0.1",
"@azure/ms-rest-js": "^2.0.7", "@azure/ms-rest-js": "^2.0.7",
"@azure/storage-blob": "^12.1.2", "@azure/storage-blob": "^12.1.2",

View File

@ -11,6 +11,12 @@ export enum CompressionMethod {
Zstd = 'zstd' Zstd = 'zstd'
} }
// The default number of retry attempts.
export const DefaultRetryAttempts = 2
// The default delay in milliseconds between retry attempts.
export const DefaultRetryDelay = 5000
// Socket timeout in milliseconds during download. If no traffic is received // Socket timeout in milliseconds during download. If no traffic is received
// over the socket during this period, the socket is destroyed and the download // over the socket during this period, the socket is destroyed and the download
// is aborted. // is aborted.

View File

@ -1,9 +1,10 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import {HttpCodes} from '@actions/http-client' import {HttpCodes, HttpClientError} from '@actions/http-client'
import { import {
IHttpClientResponse, IHttpClientResponse,
ITypedResponse ITypedResponse
} from '@actions/http-client/interfaces' } from '@actions/http-client/interfaces'
import {DefaultRetryDelay, DefaultRetryAttempts} from './constants'
export function isSuccessStatusCode(statusCode?: number): boolean { export function isSuccessStatusCode(statusCode?: number): boolean {
if (!statusCode) { if (!statusCode) {
@ -31,32 +32,48 @@ export function isRetryableStatusCode(statusCode?: number): boolean {
return retryableStatusCodes.includes(statusCode) return retryableStatusCodes.includes(statusCode)
} }
async function sleep(milliseconds: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, milliseconds))
}
export async function retry<T>( export async function retry<T>(
name: string, name: string,
method: () => Promise<T>, method: () => Promise<T>,
getStatusCode: (arg0: T) => number | undefined, getStatusCode: (arg0: T) => number | undefined,
maxAttempts = 2 maxAttempts = DefaultRetryAttempts,
delay = DefaultRetryDelay,
onError: ((arg0: Error) => T | undefined) | undefined = undefined
): Promise<T> { ): Promise<T> {
let response: T | undefined = undefined
let statusCode: number | undefined = undefined
let isRetryable = false
let errorMessage = '' let errorMessage = ''
let attempt = 1 let attempt = 1
while (attempt <= maxAttempts) { while (attempt <= maxAttempts) {
let response: T | undefined = undefined
let statusCode: number | undefined = undefined
let isRetryable = false
try { try {
response = await method() response = await method()
} catch (error) {
if (onError) {
response = onError(error)
}
isRetryable = true
errorMessage = error.message
}
if (response) {
statusCode = getStatusCode(response) statusCode = getStatusCode(response)
if (!isServerErrorStatusCode(statusCode)) { if (!isServerErrorStatusCode(statusCode)) {
return response return response
} }
}
if (statusCode) {
isRetryable = isRetryableStatusCode(statusCode) isRetryable = isRetryableStatusCode(statusCode)
errorMessage = `Cache service responded with ${statusCode}` errorMessage = `Cache service responded with ${statusCode}`
} catch (error) {
isRetryable = true
errorMessage = error.message
} }
core.debug( core.debug(
@ -68,6 +85,7 @@ export async function retry<T>(
break break
} }
await sleep(delay)
attempt++ attempt++
} }
@ -77,25 +95,42 @@ export async function retry<T>(
export async function retryTypedResponse<T>( export async function retryTypedResponse<T>(
name: string, name: string,
method: () => Promise<ITypedResponse<T>>, method: () => Promise<ITypedResponse<T>>,
maxAttempts = 2 maxAttempts = DefaultRetryAttempts,
delay = DefaultRetryDelay
): Promise<ITypedResponse<T>> { ): Promise<ITypedResponse<T>> {
return await retry( return await retry(
name, name,
method, method,
(response: ITypedResponse<T>) => response.statusCode, (response: ITypedResponse<T>) => response.statusCode,
maxAttempts maxAttempts,
delay,
// If the error object contains the statusCode property, extract it and return
// an ITypedResponse<T> so it can be processed by the retry logic.
(error: Error) => {
if (error instanceof HttpClientError) {
return {
statusCode: error.statusCode,
result: null,
headers: {}
}
} else {
return undefined
}
}
) )
} }
export async function retryHttpClientResponse<T>( export async function retryHttpClientResponse<T>(
name: string, name: string,
method: () => Promise<IHttpClientResponse>, method: () => Promise<IHttpClientResponse>,
maxAttempts = 2 maxAttempts = DefaultRetryAttempts,
delay = DefaultRetryDelay
): Promise<IHttpClientResponse> { ): Promise<IHttpClientResponse> {
return await retry( return await retry(
name, name,
method, method,
(response: IHttpClientResponse) => response.message.statusCode, (response: IHttpClientResponse) => response.message.statusCode,
maxAttempts maxAttempts,
delay
) )
} }