mirror of https://github.com/actions/toolkit
Merge pull request #558 from dhadka/dhadka/fix-error-handling
Handle errors representing non-successful http responses in retry logicpull/615/head
commit
f1b118b2a9
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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",
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue