-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Retry all http calls for artifact upload and download #675
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
ef7a575
175b04e
edbdbf9
5b7b7aa
f880c0e
b20b44f
1bcfb42
f98d4c4
00a8824
1796402
ff3709c
632e3cf
3706baf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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' | ||
| ) | ||
| }) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,74 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import {IHttpClientResponse} from '@actions/http-client/interfaces' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import {isRetryableStatusCode, isSuccessStatusCode, sleep} from './utils' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import * as core from '@actions/core' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function retry<T>( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| method: () => Promise<T>, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
konradpabjan marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| getStatusCode: (response: T) => number | undefined, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
konradpabjan marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| errorMessages: Map<number, string>, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Some API responses might have an error message in the body.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My original intention with this |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| maxAttempts: number, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| delay: number | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
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) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was mostly going off of the existing
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It shouldn't cause any repetition because
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
My original intention was to use mostly what |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 toolkit/packages/artifact/src/internal/upload-http-client.ts Lines 414 to 432 in 73d5917
What I did in this PR, is I moved the sleep method to a shared
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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 toolkit/packages/artifact/src/internal/utils.ts Lines 15 to 34 in 73d5917
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| attempt++ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (extraErrorInformation) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw Error(`${name} failed: ${errorMessage} : ${extraErrorInformation}`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw Error(`${name} failed: ${errorMessage}`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function retryHttpClientResponse<T>( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.