Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/helpers/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,5 @@ export function argAsArray<T>(args?: T | T[]): T[] {
}
return result.concat(args)
}

export function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); }
75 changes: 53 additions & 22 deletions src/helpers/web.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import dns from 'node:dns'
import { snakeCase } from 'snake-case'
import {
request,
setGlobalDispatcher,
} from 'undici'
import { request, setGlobalDispatcher, errors, Dispatcher } from 'undici'

import { version } from '../../package.json'
import {
Expand All @@ -15,8 +12,13 @@ import {
UploaderEnvs,
UploaderInputs,
} from '../types'
import { info } from './logger'
import { UploadLogger, info, logError } from './logger'
import { addProxyIfNeeded } from './proxy'
import { sleep } from './util'


const maxRetries = 4
const baseBackoffDelayMs = 1000 // Adjust this value based on your needs.

/**
*
Expand All @@ -33,7 +35,7 @@ export function populateBuildParams(
serviceParams.name = args.name || envs.CODECOV_NAME || ''
serviceParams.tag = args.tag || ''

if (typeof args.flags === "string") {
if (typeof args.flags === 'string') {
serviceParams.flags = args.flags
} else {
serviceParams.flags = args.flags.join(',')
Expand All @@ -51,12 +53,36 @@ export function getPackage(source: string): string {
}
}

async function requestWithRetry(
url: string,
options: Dispatcher.RequestOptions,
retryCount = 0,
): Promise<Dispatcher.ResponseData> {
try {
const response = await request(url, options)
return response
} catch (error: unknown) {
if (
((error instanceof errors.UndiciError && error.code == 'ECONNRESET') ||
error instanceof errors.ConnectTimeoutError ||
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this doesn't catch ETIMEDOUT.

error instanceof errors.SocketError) &&
retryCount < maxRetries
) {
const backoffDelay = baseBackoffDelayMs * 2 ** retryCount
await sleep(backoffDelay)
UploadLogger.verbose('Request to Codecov failed. Retrying...')
logError(`Request error: ${error.message}`)
return requestWithRetry(url, options, retryCount + 1)
}
throw error
}
}

export async function uploadToCodecovPUT(
putAndResultUrlPair: PostResults,
uploadFile: string | Buffer,
envs: UploaderEnvs,
args: UploaderArgs
args: UploaderArgs,
): Promise<PutResults> {
info('Uploading...')

Expand All @@ -69,8 +95,11 @@ export async function uploadToCodecovPUT(
if (requestHeaders.agent) {
setGlobalDispatcher(requestHeaders.agent)
}
dns.setDefaultResultOrder('ipv4first');
const response = await request(requestHeaders.url.origin, requestHeaders.options)
dns.setDefaultResultOrder('ipv4first')
const response = await requestWithRetry(
requestHeaders.url.origin,
requestHeaders.options,
)

if (response.statusCode !== 200) {
const data = await response.body.text()
Expand Down Expand Up @@ -101,8 +130,12 @@ export async function uploadToCodecovPOST(
if (requestHeaders.agent) {
setGlobalDispatcher(requestHeaders.agent)
}
dns.setDefaultResultOrder('ipv4first');
const response = await request(requestHeaders.url.origin, requestHeaders.options)
dns.setDefaultResultOrder('ipv4first')

const response = await requestWithRetry(
requestHeaders.url.origin,
requestHeaders.options,
)

if (response.statusCode !== 200) {
const data = await response.body.text()
Expand All @@ -113,7 +146,6 @@ export async function uploadToCodecovPOST(

return await response.body.text()
}

/**
*
* @param {Object} queryParams
Expand All @@ -125,8 +157,6 @@ export function generateQuery(queryParams: Partial<IServiceParams>): string {
).toString()
}



export function parsePOSTResults(putAndResultUrlPair: string): PostResults {
info(putAndResultUrlPair)

Expand All @@ -136,7 +166,9 @@ export function parsePOSTResults(putAndResultUrlPair: string): PostResults {
const matches = putAndResultUrlPair.match(re)

if (matches === null) {
throw new Error(`Parsing results from POST failed: (${putAndResultUrlPair})`)
throw new Error(
`Parsing results from POST failed: (${putAndResultUrlPair})`,
)
}

if (matches?.length !== 2) {
Expand All @@ -147,12 +179,12 @@ export function parsePOSTResults(putAndResultUrlPair: string): PostResults {

if (matches[0] === undefined || matches[1] === undefined) {
throw new Error(
`Invalid URLs received when parsing results from POST: ${matches[0]},${matches[1]}`
`Invalid URLs received when parsing results from POST: ${matches[0]},${matches[1]}`,
)
}
const resultURL = new URL(matches[0].trimEnd())
const putURL = new URL(matches[1])
// This match may have trailing 0x0A and 0x0D that must be trimmed
// This match may have trailing 0x0A and 0x0D that must be trimmed

return { putURL, resultURL }
}
Expand All @@ -170,9 +202,10 @@ export function generateRequestHeadersPOST(
envs: UploaderEnvs,
args: UploaderArgs,
): IRequestHeaders {
const url = new URL(`upload/v4?package=${getPackage(
source,
)}&token=${token}&${query}`, postURL)
const url = new URL(
`upload/v4?package=${getPackage(source)}&token=${token}&${query}`,
postURL,
)

const headers = {
'X-Upload-Token': token,
Expand All @@ -191,8 +224,6 @@ export function generateRequestHeadersPOST(
}
}



export function generateRequestHeadersPUT(
uploadURL: URL,
uploadFile: string | Buffer,
Expand Down
119 changes: 115 additions & 4 deletions test/helpers/web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
MockClient,
ProxyAgent,
setGlobalDispatcher,
errors
} from 'undici'

import { version } from '../../package.json'
Expand All @@ -20,6 +21,9 @@ import {
import { IServiceParams, PostResults, UploaderArgs, UploaderEnvs } from '../../src/types.js'
import { createEmptyArgs } from '../test_helpers'

import * as utilModule from '../../src/helpers/util'


describe('Web Helpers', () => {
let uploadURL: string
let token: string
Expand Down Expand Up @@ -280,10 +284,6 @@ describe('Web Helpers', () => {







https://put.codecov.local`
const expectedResults = {
putURL: 'https://put.codecov.local/',
Expand All @@ -292,6 +292,117 @@ describe('Web Helpers', () => {
expect(JSON.stringify(parsePOSTResults(testURL))).toStrictEqual(JSON.stringify(expectedResults))
})
})

describe('Uploader should retry POST on ECONNRESET', () => {
it('should retry and return response data when ECONNRESET occurs once', async () => {
const envs: UploaderEnvs = {}
const args: UploaderArgs = {
flags: '',
slug: '',
upstream: '',
}
const error = new errors.UndiciError("conn reset error")
error.code = 'ECONNRESET'
uploadURL = 'http://codecov.io'
mockAgent.disableNetConnect()
mockClient = mockAgent.get(uploadURL)

mockClient.intercept({
method: 'POST',
path: `/upload/v4?package=uploader-${version}&token=${token}&hello`,
}).replyWithError(new errors.ConnectTimeoutError('timeout error')).times(1)

mockClient.intercept({
method: 'POST',
path: `/upload/v4?package=uploader-${version}&token=${token}&hello`,
}).replyWithError(error).times(1)

mockClient.intercept({
method: 'POST',
path: `/upload/v4?package=uploader-${version}&token=${token}&hello`,
}).reply(200, 'testPOSTHTTP')

const responseData = await uploadToCodecovPOST(new URL(uploadURL), token, query, source, envs, args);
try {
expect(responseData).toBe('testPOSTHTTP')
} catch (error) {
throw new Error(`${responseData} - ${error}`)
}
});

it('should fail with error if ECONNRESET happens 5 times', async () => {
const mockSleep = jest.spyOn(utilModule, 'sleep').mockResolvedValue(42)
const envs: UploaderEnvs = {}
const args: UploaderArgs = {
flags: '',
slug: '',
upstream: '',
}
const error = new errors.UndiciError("conn reset error")
error.code = 'ECONNRESET'
uploadURL = 'http://codecov.io'
mockAgent.disableNetConnect()
mockClient = mockAgent.get(uploadURL)

mockClient.intercept({
method: 'POST',
path: `/upload/v4?package=uploader-${version}&token=${token}&hello`,
}).replyWithError(error).times(5)

mockClient.intercept({
method: 'POST',
path: `/upload/v4?package=uploader-${version}&token=${token}&hello`,
}).reply(200, 'testPOSTHTTP')

try {
await uploadToCodecovPOST(new URL(uploadURL), token, query, source, envs, args);
expect(true).toBe(false)
} catch (error) {
expect(error).toBeInstanceOf(errors.UndiciError)
expect(mockSleep).toBeCalledTimes(4)
}
})
});

describe('Uploader should retry PUT on ECONNRESET', () => {
it('should retry and return response data when ECONNRESET occurs once', async () => {
// Replace the original setTimeout with fakeSetTimeout
const envs: UploaderEnvs = {}
const args: UploaderArgs = {
flags: '',
slug: '',
upstream: '',
}
jest.spyOn(console, 'log').mockImplementation(() => void {})
const postResults: PostResults = {
putURL: new URL('https://codecov.io'),
resultURL: new URL('https://results.codecov.io'),
}

const error = new errors.UndiciError("conn reset error")
error.code = 'ECONNRESET'
mockAgent.disableNetConnect()
mockClient = mockAgent.get(postResults.putURL.origin)

mockClient.intercept({
method: 'PUT',
path: `/`,
}).replyWithError(new errors.ConnectTimeoutError('timeout error')).times(1)

mockClient.intercept({
method: 'PUT',
path: `/`,
}).replyWithError(error).times(1)

mockClient.intercept({
method: 'PUT',
path: `/`,
}).reply(200, postResults.resultURL.href)

const response = await uploadToCodecovPUT(postResults, uploadFile, envs, args)
expect(response.resultURL.href).toStrictEqual('https://results.codecov.io/')
});
});
})

describe('displayChangelog()', () => {
Expand Down