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

Draft
wants to merge 11 commits into
base: develop
Choose a base branch
from
Draft
4 changes: 4 additions & 0 deletions packages/network/lib/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,3 +441,7 @@ class HttpsAgent extends https.Agent {
const agent = new CombinedAgent()

export default agent

export const httpsAgent = new HttpsAgent()

export const httpAgent = new HttpAgent()
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}: %d %s -> \n %o`, res.status, res.statusText, res.data)

return res
}

const logResponseErr = (err: AxiosError): never => {
debug(`${err.config?.method} ${err.config?.url}: %d -> \n %o`, err.response?.statusText, err.response?.statusText, 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)
}
43 changes: 43 additions & 0 deletions packages/server/lib/cloud/api/cloud_request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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) => {
const axiosErr = axios.isAxiosError(error) ? error : undefined

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

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

// TODO: generate these types like system-tests' cloudValidations
type CreateInstanceResponse = {
instanceId: string
claimedInstances: number
estimatedWallClockDuration: number | null
spec: string | null
totalInstances: number
}

type CreateInstanceRequestData = {
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: CreateInstanceRequestData, timeout: number = 0): Promise<CreateInstanceResponse> => {
let attemptNumber = 0

return asyncRetry(async () => {
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,
},
)

attemptNumber++

return data
}, {
maxAttempts: 3,
retryDelay: exponentialBackoff(),
shouldRetry: isRetryableCloudError,
})()
}
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 @@ -24,6 +24,9 @@ import ProtocolManager from '../protocol'
import type { ProjectBase } from '../../project-base'
import type { AfterSpecDurations } from '@packages/types'

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 @@ -208,19 +211,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
21 changes: 21 additions & 0 deletions packages/server/lib/cloud/api/log_requests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { InternalAxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
import Debug from 'debug'

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

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

return req
}

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

return res
}

export const logResponseErr = (err: AxiosError): never => {
debug(`${err.config?.method} ${err.config?.url}: %d -> \n %o`, err.response?.statusText, err.response?.statusText, err.response?.data)
throw err
}
19 changes: 12 additions & 7 deletions packages/server/lib/cloud/network/is_retryable_error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@ import Debug from 'debug'

const debug = Debug('cypress-verbose:server:is-retryable-error')

export const isRetryableError = (error?: Error) => {
export const isRetryableError = (error: unknown) => {
debug('is retryable error? system error: %s, httperror: %s, status: %d',
error && SystemError.isSystemError(error),
error && HttpError.isHttpError(error),
error && SystemError.isSystemError(error as any),
error && HttpError.isHttpError(error as any),
(error as HttpError)?.status)

return error ? (
SystemError.isSystemError(error) ||
HttpError.isHttpError(error) && [408, 429, 502, 503, 504].includes(error.status)
) : false
if (SystemError.isSystemError(error as any)) {
return true
}

if (HttpError.isHttpError(error as any)) {
return [408, 429, 502, 503, 504].includes((error as HttpError).status)
}

return false
}
3 changes: 1 addition & 2 deletions packages/server/lib/modes/record.js
Original file line number Diff line number Diff line change
Expand Up @@ -501,9 +501,8 @@ const createInstance = (options = {}) => {

spec = getSpecRelativePath(spec)

return api.createInstance({
return api.createInstance(runId, {
spec,
runId,
groupId,
platform,
machineId,
Expand Down
14 changes: 13 additions & 1 deletion packages/server/lib/util/async_retry.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
type RetryOptions = {
maxAttempts: number
retryDelay?: (attempt: number) => number
shouldRetry?: (err?: Error) => boolean
shouldRetry?: (err?: unknown) => boolean
}

export function asyncRetry <
Expand Down Expand Up @@ -52,3 +52,15 @@ export const linearDelay = (inc: number) => {
return attempt * inc
}
}

export const exponentialBackoff = ({ factor, fuzz } = {
factor: 100,
fuzz: 0.1,
}) => {
return (attempt: number) => {
const exponentialComponent = 2 ** attempt * factor
const fuzzComponent = exponentialComponent * fuzz * Math.random()

return exponentialComponent + fuzzComponent
}
}
2 changes: 2 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@types/mime": "^3.0.1",
"ansi_up": "5.0.0",
"ast-types": "0.13.3",
"axios": "^1.7.9",
"base64url": "^3.0.1",
"better-sqlite3": "11.5.0",
"black-hole-stream": "0.0.1",
Expand Down Expand Up @@ -214,6 +215,7 @@
"productName": "Cypress",
"workspaces": {
"nohoist": [
"axios",
"devtools-protocol",
"edgedriver",
"geckodriver",
Expand Down
18 changes: 18 additions & 0 deletions packages/server/patches/axios+1.7.9.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
diff --git a/node_modules/axios/lib/platform/common/utils.js b/node_modules/axios/lib/platform/common/utils.js
index 52a3186..08b92cc 100644
--- a/node_modules/axios/lib/platform/common/utils.js
+++ b/node_modules/axios/lib/platform/common/utils.js
@@ -1,6 +1,11 @@
-const hasBrowserEnv = typeof window !== 'undefined' && typeof document !== 'undefined';
+/**
+ * patched due to how v8 snapshots work - axios was incorrectly determining that
+ * it was running in a browser context, leading to runtime errors.
+ */
+
+const hasBrowserEnv = false;

-const _navigator = typeof navigator === 'object' && navigator || undefined;
+const _navigator = undefined;

/**
* Determine if we're running in a standard browser environment
Loading
Loading