Skip to content

refactor: rework preflight logic to be axios middleware #31589

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

Open
wants to merge 5 commits into
base: develop
Choose a base branch
from
Open
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
4 changes: 2 additions & 2 deletions packages/network/lib/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ const getProxyOrTargetOverrideForUrl = (href) => {
return getProxyForUrl(href)
}

class HttpAgent extends http.Agent {
export class HttpAgent extends http.Agent {
httpsAgent: https.Agent

constructor (opts: http.AgentOptions = {}) {
Expand Down Expand Up @@ -317,7 +317,7 @@ class HttpAgent extends http.Agent {
}
}

class HttpsAgent extends https.Agent {
export class HttpsAgent extends https.Agent {
constructor (opts: https.AgentOptions = {}) {
opts.keepAlive = true
super(opts)
Expand Down
121 changes: 121 additions & 0 deletions packages/server/lib/cloud/api/axios_middleware/preflight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { InternalAxiosRequestConfig, AxiosInstance, AxiosResponse, isAxiosError } from 'axios'
import { getApiUrl } from '../../routes'
import { postPreflight, PreflightRequestBody, PreflightOptions } from '../endpoints/post_preflight'
import { isArray, isObject } from 'lodash'
import { asyncRetry, exponentialBackoff } from '../../../util/async_retry'
import { noProxyPreflightTimeout } from '../preflight_timeout'
import Debug from 'debug'

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

interface PreflightWarning {
message: string
}

interface PreflightState {
encrypt: boolean
apiUrl: string
warnings?: PreflightWarning[]
}

declare module 'axios' {
interface AxiosRequestConfig {
requirePreflight?: boolean
preflightState?: PreflightState
appendPreflightWarnings?: boolean
}
}

export class PreflightMiddleware {
private projectAttributes: PreflightRequestBody | undefined
constructor (private axios: AxiosInstance) {

}

setProjectAttributes (attributes: PreflightRequestBody) {
this.projectAttributes = attributes
}

async requestInterceptor (cfg: InternalAxiosRequestConfig): Promise<InternalAxiosRequestConfig> {
if (!cfg.requirePreflight) {
return cfg
}

if (cfg.requirePreflight && cfg.preflightState) {
cfg.baseURL = cfg.preflightState.apiUrl

return cfg
}

if (!this.projectAttributes) {
debug('Preflight middleware skipping request because no project attributes are set')

return cfg
}

const projectAttributes: PreflightRequestBody = this.projectAttributes

try {
const options: PreflightOptions = {
apiUrl: getApiUrl().replace('api', 'api-proxy'),
attempt: 1,
timeout: noProxyPreflightTimeout(),
}

const preflightState = await postPreflight(projectAttributes, options)

cfg.preflightState = preflightState
this.axios.defaults.preflightState = preflightState
} catch (e) {
if (isAxiosError(e) && e.status === 412) {
throw e
}

let attempt = 0
const retryPreflight = asyncRetry(
async () => {
attempt++

return postPreflight(projectAttributes, {
apiUrl: getApiUrl(),
httpAgent: this.axios.defaults.httpAgent,
httpsAgent: this.axios.defaults.httpsAgent,
attempt,
})
},
{
maxAttempts: 3, // Will make 3 total attempts (initial + 2 retries)
retryDelay: exponentialBackoff({ factor: 1000, fuzz: 0.1 }), // Start with 1 second, double each time
shouldRetry: (err) => {
if (isAxiosError(err) && err.status === 412) {
return false // Don't retry on 412 errors
}

return true
},
},
)

const preflightState = await retryPreflight()

cfg.preflightState = preflightState
this.axios.defaults.preflightState = preflightState
}

return cfg
}

responseInterceptor (res: AxiosResponse): AxiosResponse {
if (res.config.appendPreflightWarnings && res.config.preflightState?.warnings) {
if (isArray(res.data.warnings)) {
res.data.warnings = (res.data.warnings as PreflightWarning[]).concat(res.config.preflightState?.warnings)
} else if (isObject(res.data.warnings)) {
res.data.warnings = [res.data.warnings as PreflightWarning, ...(res.config.preflightState.warnings as PreflightWarning[])]
} else {
res.data.warnings = res.config.preflightState?.warnings
}
}

return res
}
}
22 changes: 17 additions & 5 deletions packages/server/lib/cloud/api/cloud_request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,42 @@ import os from 'os'
import axios, { AxiosInstance } from 'axios'

import pkg from '@packages/root'
import { httpAgent, httpsAgent } from '@packages/network/lib/agent'
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 => {
export const _create = (withAgents: boolean = true): 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,
httpAgent: withAgents ? new HttpAgent() : undefined,
httpsAgent: withAgents ? new HttpsAgent({ rejectUnauthorized: true }) : undefined,
headers: {
'x-os-name': os.platform(),
'x-cypress-version': pkg.version,
'User-Agent': `cypress/${pkg.version}`,
'x-os-name': os.platform(),
},
// this must be disabled, because we are using our own agents
proxy: false,
})

installLogging(instance)
installErrorTransform(instance)

// Because of how sinon.stub works, this needs to query `os` at
// request time rather than creation time
if (process.env.CYPRESS_INTERNAL_ENV === 'test') {
instance.interceptors.request.use((cfg) => {
cfg.headers['x-os-name'] = os.platform()

return cfg
})
}

return instance
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CloudRequest, isRetryableCloudError } from './cloud_request'
import { asyncRetry, exponentialBackoff } from '../../util/async_retry'
import * as errors from '../../errors'
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
Expand Down
64 changes: 64 additions & 0 deletions packages/server/lib/cloud/api/endpoints/post_preflight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import axios from 'axios'
import { isArray, isBoolean, isString, isObject } from 'lodash'
import type { HttpAgent, HttpsAgent } from '@packages/network/lib/agent'

export interface PreflightWarning {
message: string
}

export interface PreflightState {
encrypt: boolean
apiUrl: string
warnings?: PreflightWarning[]
}

export interface PreflightRequestBody {
projectId: string
projectRoot: string
ciBuildId: string
browser: Record<string, any>
testingType: 'e2e' | 'component'
parallel: boolean
}

export interface PreflightOptions {
apiUrl: string
attempt?: number
httpAgent?: HttpAgent
httpsAgent?: HttpsAgent
timeout?: number
}

function isValidPreflightState (state: unknown): state is PreflightState {
if (!isObject(state)) return false

const s = state as Record<string, unknown>

return isBoolean(s.encrypt) &&
isString(s.apiUrl) &&
(!s.warnings || (isArray(s.warnings) && s.warnings.every((warning) => {
return isObject(warning) && isString((warning as Record<string, unknown>).message)
})))
}

export async function postPreflight (body: PreflightRequestBody, options: PreflightOptions): Promise<PreflightState> {
const instance = axios.create({
baseURL: options.apiUrl,
httpAgent: options.httpAgent,
httpsAgent: options.httpsAgent,
})

const response = await instance.post('/preflight', body, {
headers: {
'x-route-version': '1',
'x-cypress-request-attempt': options.attempt?.toString() ?? '1',
},
timeout: options.timeout,
})

if (!isValidPreflightState(response.data)) {
throw new TypeError('Invalid preflight state received from server')
}

return response.data
}
21 changes: 5 additions & 16 deletions packages/server/lib/cloud/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@ const errors = require('../../errors')
import Bluebird from 'bluebird'

import type { AfterSpecDurations } from '@packages/types'
import { agent } from '@packages/network'
import { default as agent } from '@packages/network/lib/agent'
import type { CombinedAgent } from '@packages/network/lib/agent'

import { apiUrl, apiRoutes, makeRoutes } from '../routes'
import { getText } from '../../util/status_code'
import * as enc from '../encryption'
Expand All @@ -33,10 +32,10 @@ import { PUBLIC_KEY_VERSION } from '../constants'
// axios implementation disabled until proxy issues can be diagnosed/fixed
// TODO: https://github.com/cypress-io/cypress/issues/31490
//import { createInstance } from './create_instance'
import type { CreateInstanceRequestBody, CreateInstanceResponse } from './create_instance'
import type { CreateInstanceRequestBody, CreateInstanceResponse } from './endpoints/create_instance'

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

import { noProxyPreflightTimeout } from './preflight_timeout'
const THIRTY_SECONDS = humanInterval('30 seconds')
const SIXTY_SECONDS = humanInterval('60 seconds')
const TWO_MINUTES = humanInterval('2 minutes')
Expand Down Expand Up @@ -73,7 +72,7 @@ export interface CypressRequestOptions extends OptionsWithUrl {
}

// TODO: migrate to fetch from @cypress/request
const rp = request.defaults((params: CypressRequestOptions, callback) => {
export const rp = request.defaults((params: CypressRequestOptions, callback) => {
let resp

if (params.cacheable && (resp = getCachedResponse(params))) {
Expand Down Expand Up @@ -240,16 +239,6 @@ const isRetriableError = (err) => {
(err.statusCode == null)
}

function noProxyPreflightTimeout (): number {
try {
const timeoutFromEnv = Number(process.env.CYPRESS_INITIAL_PREFLIGHT_TIMEOUT)

return isNaN(timeoutFromEnv) ? 5000 : timeoutFromEnv
} catch (e: unknown) {
return 5000
}
}

export type CreateRunOptions = {
projectRoot: string
ci: {
Expand Down Expand Up @@ -363,7 +352,7 @@ export default {
}
},

ping () {
async ping () {
return rp.get(apiRoutes.ping())
.catch(tagError)
},
Expand Down
11 changes: 11 additions & 0 deletions packages/server/lib/cloud/api/preflight_timeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const DEFAULT_PREFLIGHT_TIMEOUT = 5000

export function noProxyPreflightTimeout (): number {
try {
const timeoutFromEnv = Number(process.env.CYPRESS_INITIAL_PREFLIGHT_TIMEOUT)

return (isNaN(timeoutFromEnv) || process.env.CYPRESS_INITIAL_PREFLIGHT_TIMEOUT === '') ? DEFAULT_PREFLIGHT_TIMEOUT : timeoutFromEnv
} catch (e: unknown) {
return DEFAULT_PREFLIGHT_TIMEOUT
}
}
13 changes: 12 additions & 1 deletion packages/server/lib/cloud/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,18 @@ interface DependencyInformation {
}

// See https://whimsical.com/encryption-logic-BtJJkN7TxacK8kaHDgH1zM for more information on what this is doing
const getEnvInformationForProjectRoot = async (projectRoot: string, pid: string) => {
interface EnvInformation {
envUrl: string | undefined
errors: {
dependency?: string | undefined
name: string
message: string
stack: string
}[]
dependencies: {}
}

const getEnvInformationForProjectRoot = async (projectRoot: string, pid: string): Promise<EnvInformation> => {
let dependencies = {}
let errors: { dependency?: string, name: string, message: string, stack: string }[] = []
let envDependencies = process.env.CYPRESS_ENV_DEPENDENCIES
Expand Down
4 changes: 4 additions & 0 deletions packages/server/lib/cloud/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ const app_config = require('../../config/app.json')

export const apiUrl = app_config[process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'development'].api_url

export function getApiUrl () {
return apiUrl
}

const CLOUD_ENDPOINTS = {
api: '',
auth: 'auth',
Expand Down
Loading
Loading