-
Notifications
You must be signed in to change notification settings - Fork 1.7k
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 all 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,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' | ||
| }) | ||
| }) |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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() | ||||
konradpabjan marked this conversation as resolved.
Show resolved
Hide resolved
|
||||
| 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) | ||||
|
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`) | ||||
| if (response) { | ||||
| displayHttpDiagnostics(response) | ||||
| } | ||||
| break | ||||
| } | ||||
|
|
||||
| core.info( | ||||
| `${name} - Attempt ${attempt} of ${maxAttempts} failed with error: ${errorMessage}` | ||||
| ) | ||||
|
|
||||
| await sleep(getExponentialRetryTimeInMilliseconds(attempt)) | ||||
| attempt++ | ||||
| } | ||||
|
|
||||
| if (response) { | ||||
|
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 want to move this closer to the actual call, so we can log diagnostic info of every operation. Otherwise LGTM.
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. Line 27 in this same file. We should just log it there.
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'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:
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) | ||||
| } | ||||
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.