Skip to content
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

refactor: begin to use axios for cloud api requests #31041

Open
wants to merge 34 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
295415f
patch axios for v8 snapshots
cacieprins Dec 9, 2024
f52170d
export httpsAgent and httpAgent discretely
cacieprins Dec 9, 2024
ba3ae4b
set up axios interceptors for logging and error response transformation
cacieprins Dec 9, 2024
dce7b9f
use unified error transform fn
cacieprins Dec 9, 2024
715ece3
create instance api reqs, timeouts, tests
cacieprins Dec 9, 2024
ad4ac12
Merge branch 'develop' into refactor/axios/create-instance
cacieprins Feb 4, 2025
b7867d4
move axios middleware to its own dir & refactor
cacieprins Feb 4, 2025
db47562
refactor error handling, enable retries in createInstance
cacieprins Feb 6, 2025
1a90622
Merge branch 'develop' into refactor/axios/create-instance
cacieprins Feb 6, 2025
91eecc7
fix invocation of createInstance - not caught by ts because record.js…
cacieprins Feb 6, 2025
7a257cd
Merge branch 'develop' into refactor/axios/create-instance
cacieprins Feb 6, 2025
6cfb86f
retry on 500 - according to system test, this is expected behavior
cacieprins Feb 6, 2025
c4477fc
resolve snapshots, report retries to stdout
cacieprins Feb 12, 2025
c98e49a
Merge branch 'develop' into refactor/axios/create-instance
cacieprins Feb 12, 2025
3f1e589
fix cdp connection usage of shouldRetry due to newly unknown error type
cacieprins Feb 12, 2025
27549bd
axios doesnt fully follow RequestOptions shape when adding request to…
cacieprins Feb 12, 2025
6993fd9
note why uri is treated as optional
cacieprins Feb 12, 2025
116a906
hail mary on getting axios to work with v8 snapshots
cacieprins Feb 13, 2025
4db0f6e
update lockfile, force no-rewrite on more axios files
cacieprins Feb 13, 2025
e4ec76c
attempt to fix v8 snapshots
ryanthemanuel Feb 18, 2025
02a512c
Merge branch 'develop' into refactor/axios/create-instance
cacieprins Feb 24, 2025
424ed0b
add verbose debugging to api request logging
cacieprins Feb 26, 2025
b0c46c7
enable verbose api debugging on server unit tests
cacieprins Feb 26, 2025
751221d
fix nock pattern for createInstance
cacieprins Feb 26, 2025
b5be6d2
remove request logging unit test - sinon/mocha does not assert correctly
cacieprins Feb 26, 2025
160b219
fix a few unit tests
cacieprins Feb 26, 2025
d598029
use runAllAsync rather than waiting an arbitrary time for sinon fake …
cacieprins Feb 27, 2025
756afe4
move create_instance spec to ts file, remove redundant test
cacieprins Feb 27, 2025
7715b48
Merge branch 'develop' into refactor/axios/create-instance
cacieprins Feb 27, 2025
1882a18
rm debug on ci
cacieprins Feb 27, 2025
b9a2ec8
clarify comment on change inpackages/network
cacieprins Feb 27, 2025
2bd3461
Merge branch 'develop' into refactor/axios/create-instance
cacieprins Feb 28, 2025
8da8804
Merge branch 'develop' into refactor/axios/create-instance
cacieprins Mar 3, 2025
85cb89c
Merge branch 'develop' into refactor/axios/create-instance
cacieprins Mar 4, 2025
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
20 changes: 15 additions & 5 deletions packages/errors/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,25 +155,31 @@ export const AllCypressErrors = {
CLOUD_CANCEL_SKIPPED_SPEC: () => {
return errTemplate`${fmt.off(`\n `)}This spec and its tests were skipped because the run has been canceled.`
},
CLOUD_API_RESPONSE_FAILED_RETRYING: (arg1: {tries: number, delayMs: number, response: Error}) => {
CLOUD_API_RESPONSE_FAILED_RETRYING: (
arg1: {tries: number, delayMs: number, response: Error },
) => {
const time = pluralize('time', arg1.tries)
const delay = humanTime.long(arg1.delayMs, false)

const message = arg1.response.name === 'AxiosError' ? arg1.response.message : `${arg1.response.name}: ${arg1.response.message}`

return errTemplate`\
We encountered an unexpected error communicating with our servers.

${fmt.highlightSecondary(arg1.response)}
${fmt.highlightSecondary(message)}

We will retry ${fmt.off(arg1.tries)} more ${fmt.off(time)} in ${fmt.off(delay)}...
`
/* Because of fmt.listFlags() and fmt.listItems() */
/* eslint-disable indent */
},
CLOUD_CANNOT_PROCEED_IN_PARALLEL: (arg1: {flags: any, response: Error}) => {
const message = arg1.response.name === 'AxiosError' ? arg1.response.message : `${arg1.response.name}: ${arg1.response.message}`

return errTemplate`\
We encountered an unexpected error communicating with our servers.

${fmt.highlightSecondary(arg1.response)}
${fmt.highlightSecondary(message)}

Because you passed the ${fmt.flag(`--parallel`)} flag, this run cannot proceed since it requires a valid response from our servers.

Expand All @@ -183,10 +189,12 @@ export const AllCypressErrors = {
})}`
},
CLOUD_CANNOT_PROCEED_IN_SERIAL: (arg1: {flags: any, response: Error}) => {
const message = arg1.response.name === 'AxiosError' ? arg1.response.message : `${arg1.response.name}: ${arg1.response.message}`

return errTemplate`\
We encountered an unexpected error communicating with our servers.

${fmt.highlightSecondary(arg1.response)}
${fmt.highlightSecondary(message)}

Because you passed the ${fmt.flag(`--record`)} flag, this run cannot proceed since it requires a valid response from our servers.

Expand All @@ -196,10 +204,12 @@ export const AllCypressErrors = {
})}`
},
CLOUD_UNKNOWN_INVALID_REQUEST: (arg1: {flags: any, response: Error}) => {
const message = arg1.response.name === 'AxiosError' ? arg1.response.message : `${arg1.response.name}: ${arg1.response.message}`

return errTemplate`\
We encountered an unexpected error communicating with our servers.

${fmt.highlightSecondary(arg1.response)}
${fmt.highlightSecondary(message)}

There is likely something wrong with the request.

Expand Down
14 changes: 12 additions & 2 deletions packages/network/lib/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,8 +330,14 @@ class HttpsAgent extends https.Agent {
// (https://github.com/nodejs/node/blob/master/lib/_http_client.js#L164) since we are a combined agent
// rather than an http or https agent. This will cause issues with fetch requests (@cypress/request already handles it:
// https://github.com/cypress-io/request/blob/master/request.js#L301-L303)
if (!options.uri.port && options.uri.protocol === 'https:') {
options.uri.port = String(443)
// The combined agent adds this when it calls addRequest, but axios calls this
// directly.
if (!options?.uri?.port && options?.uri?.protocol === 'https:') {
options.uri = {
...options.uri,
port: String(443),
}

options.port = 443
}

Expand Down Expand Up @@ -441,3 +447,7 @@ class HttpsAgent extends https.Agent {
const agent = new CombinedAgent()

export default agent

export const httpsAgent = new HttpsAgent()

export const httpAgent = new HttpAgent()
2 changes: 1 addition & 1 deletion packages/server/lib/browsers/cdp-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export class CDPConnection {
return 100
},
shouldRetry (err) {
return !(err && CDPTerminatedError.isCDPTerminatedError(err))
return !(err && err instanceof Error && CDPTerminatedError.isCDPTerminatedError(err))
},
})()

Expand Down
26 changes: 26 additions & 0 deletions packages/server/lib/cloud/api/axios_middleware/logging.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { InternalAxiosRequestConfig, AxiosResponse, AxiosError, AxiosInstance } from 'axios'
import Debug from 'debug'

const debug = Debug('cypress:server:cloud:api')

const logRequest = (req: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
debug(`${req.method} ${req.url}`)

return req
}

const logResponse = (res: AxiosResponse): AxiosResponse => {
debug(`${res.config.method} ${res.config.url} Success: %d %s -> \n Response: %o`, res.status, res.statusText, res.data)

return res
}

const logResponseErr = (err: AxiosError): never => {
debug(`${err.config?.method} ${err.config?.url} Error: %s -> \n Response: %o`, err.response?.statusText || err.code, err.response?.data)
throw err
}

export const installLogging = (axios: AxiosInstance) => {
axios.interceptors.request.use(logRequest)
axios.interceptors.response.use(logResponse, logResponseErr)
}
28 changes: 28 additions & 0 deletions packages/server/lib/cloud/api/axios_middleware/transform_error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { isObject } from 'lodash'
import axios, { AxiosError, AxiosInstance } from 'axios'

declare module 'axios' {
export interface AxiosError {
isApiError?: boolean
}
}

export const transformError = (err: AxiosError | Error & { error?: any, statusCode: number, isApiError?: boolean }): never => {
const { data, status } = axios.isAxiosError(err) ?
{ data: err.response?.data, status: err.status } :
{ data: err.error, status: err.statusCode }

if (isObject(data)) {
const body = JSON.stringify(data, null, 2)

err.message = [status, body].join('\n\n')
}

err.isApiError = true

throw err
}

export const installErrorTransform = (axios: AxiosInstance) => {
axios.interceptors.response.use(undefined, transformError)
}
50 changes: 50 additions & 0 deletions packages/server/lib/cloud/api/cloud_request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import os from 'os'

import axios, { AxiosInstance } from 'axios'

import pkg from '@packages/root'
import { httpAgent, httpsAgent } from '@packages/network/lib/agent'

import app_config from '../../../config/app.json'
import { installErrorTransform } from './axios_middleware/transform_error'
import { installLogging } from './axios_middleware/logging'

// initialized with an export for testing purposes
export const _create = (): AxiosInstance => {
const cfgKey = process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'development'

const instance = axios.create({
baseURL: app_config[cfgKey].api_url,
httpAgent,
httpsAgent,
headers: {
'x-os-name': os.platform(),
'x-cypress-version': pkg.version,
'User-Agent': `cypress/${pkg.version}`,
},
})

installLogging(instance)
installErrorTransform(instance)

return instance
}

export const CloudRequest = _create()

export const isRetryableCloudError = (error: unknown) => {
// setting this env via mocha's beforeEach coerces this to a string, even if it's a boolean
const disabled = process.env.DISABLE_API_RETRIES && process.env.DISABLE_API_RETRIES !== 'false'

if (disabled) {
return false
}

const axiosErr = axios.isAxiosError(error) ? error : undefined

if (axiosErr && axiosErr.status) {
return [408, 429, 500, 502, 503, 504].includes(axiosErr.status)
}

return true
}
68 changes: 68 additions & 0 deletions packages/server/lib/cloud/api/create_instance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { CloudRequest, isRetryableCloudError } from './cloud_request'
import { asyncRetry, exponentialBackoff } from '../../util/async_retry'
import * as errors from '../../errors'
import { isAxiosError } from 'axios'

const MAX_RETRIES = 3

interface CreateInstanceResponse {
spec: string | null
instanceId: string | null
claimedInstances: number
estimatedWallClockDuration: number | null
totalInstances: number
}

interface CreateInstanceRequestBody {
spec: string | null
groupId: string
machineId: string
platform: {
browserName: string
browserVersion: string
osCpus: any[]
osMemory: Record<string, any> | null
osName: string
osVersion: string
}
}

export const createInstance = async (runId: string, instanceData: CreateInstanceRequestBody, timeout: number = 0): Promise<CreateInstanceResponse> => {
let attemptNumber = 0

return asyncRetry(async () => {
try {
const { data } = await CloudRequest.post<CreateInstanceResponse>(
`/runs/${runId}/instances`,
instanceData,
{
headers: {
'x-route-version': '5',
'x-cypress-run-id': runId,
'x-cypress-request-attempt': `${attemptNumber}`,
},
timeout,
},
)

return data
} catch (err: unknown) {
attemptNumber++

throw err
}
}, {
maxAttempts: MAX_RETRIES,
retryDelay: exponentialBackoff(),
shouldRetry: isRetryableCloudError,
onRetry: (delay, err) => {
errors.warning(
'CLOUD_API_RESPONSE_FAILED_RETRYING', {
delayMs: delay,
tries: MAX_RETRIES - attemptNumber,
response: isAxiosError(err) ? err : err instanceof Error ? err : new Error(String(err)),
},
)
},
})()
}
54 changes: 9 additions & 45 deletions packages/server/lib/cloud/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ import type { ProjectBase } from '../../project-base'
import type { AfterSpecDurations } from '@packages/types'
import { PUBLIC_KEY_VERSION } from '../constants'

import { createInstance } from './create_instance'
import { transformError } from './axios_middleware/transform_error'

const THIRTY_SECONDS = humanInterval('30 seconds')
const SIXTY_SECONDS = humanInterval('60 seconds')
const TWO_MINUTES = humanInterval('2 minutes')
Expand Down Expand Up @@ -207,19 +210,6 @@ const retryWithBackoff = (fn) => {
return attempt(0)
}

const formatResponseBody = function (err) {
// if the body is JSON object
if (_.isObject(err.error)) {
// transform the error message to include the
// stringified body (represented as the 'error' property)
const body = JSON.stringify(err.error, null, 2)

err.message = [err.statusCode, body].join('\n\n')
}

throw err
}

const tagError = function (err) {
err.isApiError = true
throw err
Expand Down Expand Up @@ -444,37 +434,11 @@ export default {

return result
})
.catch(RequestErrors.StatusCodeError, formatResponseBody)
.catch(RequestErrors.StatusCodeError, transformError)
.catch(tagError)
},

createInstance (options) {
const { runId, timeout } = options

const body = _.pick(options, [
'spec',
'groupId',
'machineId',
'platform',
])

return retryWithBackoff((attemptIndex) => {
return rp.post({
body,
url: recordRoutes.instances(runId),
json: true,
encrypt: preflightResult.encrypt,
timeout: timeout ?? SIXTY_SECONDS,
headers: {
'x-route-version': '5',
'x-cypress-run-id': runId,
'x-cypress-request-attempt': attemptIndex,
},
})
})
.catch(RequestErrors.StatusCodeError, formatResponseBody)
.catch(tagError)
},
createInstance,

postInstanceTests (options) {
const { instanceId, runId, timeout, ...body } = options
Expand All @@ -492,7 +456,7 @@ export default {
},
body,
})
.catch(RequestErrors.StatusCodeError, formatResponseBody)
.catch(RequestErrors.StatusCodeError, transformError)
.catch(tagError)
})
},
Expand All @@ -512,7 +476,7 @@ export default {

},
})
.catch(RequestErrors.StatusCodeError, formatResponseBody)
.catch(RequestErrors.StatusCodeError, transformError)
.catch(tagError)
})
},
Expand All @@ -532,7 +496,7 @@ export default {
'x-cypress-request-attempt': attemptIndex,
},
})
.catch(RequestErrors.StatusCodeError, formatResponseBody)
.catch(RequestErrors.StatusCodeError, transformError)
.catch(tagError)
})
},
Expand All @@ -559,7 +523,7 @@ export default {
'metadata',
]),
})
.catch(RequestErrors.StatusCodeError, formatResponseBody)
.catch(RequestErrors.StatusCodeError, transformError)
.catch(tagError)
})
},
Expand Down
Loading
Loading