Skip to content

Commit 9837082

Browse files
internal: (studio) do not retry on cert failures (#32631)
* fix: ensure that all calls to api.cypress.io and cloud.cypress.io properly set rejectUnauthorized * fix: ensure that all calls to api.cypress.io and cloud.cypress.io properly set rejectUnauthorized * Fix SSL certificate verification for cloud requests Fixed SSL certificate verification for Cypress cloud requests. * fix: ensure that all calls to api.cypress.io and cloud.cypress.io properly set rejectUnauthorized * internal: (studio) do not retry on cert failures * Update packages/server/test/unit/cloud/api/studio/post_studio_session_spec.ts Co-authored-by: Matt Schile <[email protected]> * Update packages/server/test/unit/cloud/api/studio/post_studio_session_spec.ts Co-authored-by: Matt Schile <[email protected]> * fix types --------- Co-authored-by: Matt Schile <[email protected]>
1 parent 3b80385 commit 9837082

18 files changed

+223
-103
lines changed
Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/* eslint-disable padding-line-between-statements */
22
// created by autobarrel, do not modify directly
33

4-
export * from './enumTypes'
5-
export * from './inputTypes'
6-
export * from './interfaceTypes'
7-
export * from './objectTypes'
8-
export * from './scalarTypes'
9-
export * from './unions'
4+
export * from './enumTypes/'
5+
export * from './inputTypes/'
6+
export * from './interfaceTypes/'
7+
export * from './objectTypes/'
8+
export * from './scalarTypes/'
9+
export * from './unions/'

packages/server/lib/cloud/api/index.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import type { CreateInstanceRequestBody, CreateInstanceResponse } from './create
3737

3838
import { transformError } from './axios_middleware/transform_error'
3939
import { DecryptionError } from './cloud_request_errors'
40+
import { isNonRetriableCertErrorCode } from '../network/nonretriable_cert_error_codes'
4041

4142
const THIRTY_SECONDS = humanInterval('30 seconds')
4243
const SIXTY_SECONDS = humanInterval('60 seconds')
@@ -169,7 +170,7 @@ const getCachedResponse = (params) => {
169170
return responseCache[params.url]
170171
}
171172

172-
const retryWithBackoff = (fn) => {
173+
const retryWithBackoff = (fn, options: { displayRetryErrors?: boolean } = { displayRetryErrors: true }) => {
173174
if (process.env.DISABLE_API_RETRIES) {
174175
debug('api retries disabled')
175176

@@ -192,13 +193,15 @@ const retryWithBackoff = (fn) => {
192193

193194
const delayMs = delays[retryIndex]
194195

195-
errors.warning(
196-
'CLOUD_API_RESPONSE_FAILED_RETRYING', {
197-
delayMs,
198-
tries: delays.length - retryIndex,
199-
response: err,
200-
},
201-
)
196+
if (options.displayRetryErrors) {
197+
errors.warning(
198+
'CLOUD_API_RESPONSE_FAILED_RETRYING', {
199+
delayMs,
200+
tries: delays.length - retryIndex,
201+
response: err,
202+
},
203+
)
204+
}
202205

203206
retryIndex++
204207

@@ -227,6 +230,10 @@ const isRetriableError = (err) => {
227230
return false
228231
}
229232

233+
if (err.cause?.code && isNonRetriableCertErrorCode(err.cause?.code)) {
234+
return false
235+
}
236+
230237
return err instanceof Bluebird.TimeoutError ||
231238
(err.statusCode >= 500 && err.statusCode < 600) ||
232239
(err.statusCode == null)
@@ -674,7 +681,7 @@ export default {
674681
})
675682
},
676683

677-
async getCaptureProtocolScript (url: string) {
684+
async getCaptureProtocolScript (url: string, options: { displayRetryErrors?: boolean } = { displayRetryErrors: true }) {
678685
// TODO(protocol): Ensure this is removed in production
679686
if (process.env.CYPRESS_LOCAL_PROTOCOL_PATH) {
680687
debugProtocol(`Loading protocol via script at local path %s`, process.env.CYPRESS_LOCAL_PROTOCOL_PATH)
@@ -694,7 +701,7 @@ export default {
694701
encrypt: 'signed',
695702
resolveWithFullResponse: true,
696703
})
697-
})
704+
}, options)
698705

699706
const verified = enc.verifySignature(res.body, res.headers['x-cypress-signature'])
700707

packages/server/lib/cloud/api/put_protocol_artifact.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import fs from 'fs'
33
import Debug from 'debug'
44
import { StreamActivityMonitor } from '../upload/stream_activity_monitor'
55
import { asyncRetry, linearDelay } from '../../util/async_retry'
6-
import { putFetch, ParseKinds } from '../network/put_fetch'
6+
import { putFetch, ParseKinds } from '../network/fetch'
77
import { isRetryableError } from '../network/is_retryable_error'
88
const debug = Debug('cypress:server:cloud:api:protocol-artifact')
99

packages/server/lib/cloud/api/studio/get_studio_bundle.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { strictAgent } from '@packages/network'
66
import { PUBLIC_KEY_VERSION } from '../../constants'
77
import { createWriteStream } from 'fs'
88
import { verifySignatureFromFile } from '../../encryption'
9+
import { HttpError } from '../../network/http_error'
10+
import { SystemError } from '../../network/system_error'
911

1012
const pkg = require('@packages/root')
1113
const _delay = linearDelay(500)
@@ -37,7 +39,9 @@ export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: st
3739
})
3840

3941
if (!response.ok) {
40-
throw new Error(`Failed to download studio bundle: ${response.statusText}`)
42+
const err = await HttpError.fromResponse(response)
43+
44+
throw err
4145
}
4246

4347
responseSignature = response.headers.get('x-cypress-signature')
@@ -71,6 +75,17 @@ export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: st
7175
throw new Error('Studio bundle fetch timed out')
7276
}
7377

78+
if (HttpError.isHttpError(error)) {
79+
throw error
80+
}
81+
82+
if (error.errno || error.code) {
83+
const sysError = new SystemError(error, studioUrl, error.code, error.errno)
84+
85+
sysError.stack = error.stack
86+
throw sysError
87+
}
88+
7489
throw error
7590
}
7691
}, {

packages/server/lib/cloud/api/studio/post_studio_session.ts

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { asyncRetry, linearDelay } from '../../../util/async_retry'
22
import { isRetryableError } from '../../network/is_retryable_error'
3-
import fetch from 'cross-fetch'
43
import os from 'os'
5-
import { strictAgent } from '@packages/network'
4+
import { ParseKinds, postFetch } from '../../network/fetch'
65

76
const pkg = require('@packages/root')
87
const routes = require('../../routes') as typeof import('../../routes')
@@ -15,28 +14,15 @@ const _delay = linearDelay(500)
1514

1615
export const postStudioSession = async ({ projectId }: PostStudioSessionOptions) => {
1716
return await (asyncRetry(async () => {
18-
const response = await fetch(routes.apiRoutes.studioSession(), {
19-
// @ts-expect-error - this is supported
20-
agent: strictAgent,
21-
method: 'POST',
17+
return postFetch<{ studioUrl: string, protocolUrl: string }>(routes.apiRoutes.studioSession(), {
18+
parse: ParseKinds.JSON,
2219
headers: {
2320
'Content-Type': 'application/json',
2421
'x-os-name': os.platform(),
2522
'x-cypress-version': pkg.version,
2623
},
2724
body: JSON.stringify({ projectSlug: projectId, studioMountVersion: 1, protocolMountVersion: 2 }),
2825
})
29-
30-
if (!response.ok) {
31-
throw new Error(`Failed to create studio session: ${response.statusText}`)
32-
}
33-
34-
const data = await response.json()
35-
36-
return {
37-
studioUrl: data.studioUrl,
38-
protocolUrl: data.protocolUrl,
39-
}
4026
}, {
4127
maxAttempts: 3,
4228
retryDelay: _delay,

packages/server/lib/cloud/api/studio/report_studio_error.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ interface StudioError {
1717
name: string
1818
stack: string
1919
message: string
20+
code?: string | number
21+
errno?: string | number
2022
studioMethod: string
2123
studioMethodArgs?: string
2224
}
@@ -80,6 +82,8 @@ export function reportStudioError ({
8082
name: stripPath(errorObject.name ?? `Unknown name`),
8183
stack: stripPath(errorObject.stack ?? `Unknown stack`),
8284
message: stripPath(errorObject.message ?? `Unknown message`),
85+
code: 'code' in errorObject ? errorObject.code as string | number : undefined,
86+
errno: 'errno' in errorObject ? errorObject.errno as string | number : undefined,
8387
studioMethod,
8488
studioMethodArgs: studioMethodArgsString ? stripPath(studioMethodArgsString) : undefined,
8589
}],

packages/server/lib/cloud/network/put_fetch.ts renamed to packages/server/lib/cloud/network/fetch.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import Debug from 'debug'
77

88
const debug = Debug('cypress-verbose:server:cloud:api:put')
99

10-
type PutInit = Omit<RequestInit, 'agent' | 'method'>
10+
type FetchInit = Omit<RequestInit, 'agent'>
11+
12+
type MethodlessFetchInit = Omit<FetchInit, 'method'>
1113

1214
export const ParseKinds = Object.freeze({
1315
JSON: 'json',
@@ -16,23 +18,46 @@ export const ParseKinds = Object.freeze({
1618

1719
type ParseKind = typeof ParseKinds[keyof typeof ParseKinds]
1820

19-
type PutOptions = PutInit & {
21+
type FetchOptions = FetchInit & {
2022
parse?: ParseKind
2123
}
2224

23-
export async function putFetch<
25+
type MethodlessFetchOptions = MethodlessFetchInit & {
26+
parse: ParseKind
27+
}
28+
29+
export function putFetch<
30+
TReturn,
31+
> (input: RequestInfo | URL, options: MethodlessFetchOptions): Promise<TReturn> {
32+
return fetch(input, {
33+
...options,
34+
method: 'PUT',
35+
})
36+
}
37+
38+
export function postFetch<
39+
TReturn,
40+
> (input: RequestInfo | URL, options: MethodlessFetchOptions): Promise<TReturn> {
41+
return fetch(input, {
42+
...options,
43+
method: 'POST',
44+
})
45+
}
46+
47+
export async function fetch<
2448
TReturn,
25-
> (input: RequestInfo | URL, options: PutOptions = { parse: 'json' }): Promise<TReturn> {
49+
> (input: RequestInfo | URL, options: FetchOptions): Promise<TReturn> {
2650
const {
2751
parse,
52+
method,
2853
...init
2954
} = options
3055

31-
debug('Initiating PUT %s', input)
56+
debug('Initiating %s %s', method, input)
3257
try {
3358
const response = await crossFetch(input, {
3459
...(init || {}),
35-
method: 'PUT',
60+
method,
3661
// cross-fetch thinks this is in the browser, so declares
3762
// types based on that rather than on node-fetch which it
3863
// actually uses under the hood. node-fetch supports `agent`.
@@ -74,7 +99,7 @@ export async function putFetch<
7499
const url = typeof input === 'string' ? input :
75100
input instanceof URL ? input.href :
76101
input instanceof Request ? input.url : 'UNKNOWN_URL'
77-
const sysError = new SystemError(err, url)
102+
const sysError = new SystemError(err, url, err.code, err.errno)
78103

79104
sysError.stack = err.stack
80105
throw sysError

packages/server/lib/cloud/network/is_retryable_error.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,26 @@
11
import { SystemError } from './system_error'
22
import { HttpError } from './http_error'
33
import Debug from 'debug'
4+
import { isNonRetriableCertErrorCode } from './nonretriable_cert_error_codes'
45

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

7-
export const isRetryableError = (error: unknown) => {
8+
export const isRetryableError = (error: any) => {
89
debug('is retryable error? system error: %s, httperror: %s, status: %d',
910
error && SystemError.isSystemError(error as any),
1011
error && HttpError.isHttpError(error as any),
1112
(error as HttpError)?.status)
1213

13-
if (SystemError.isSystemError(error as any)) {
14+
if (SystemError.isSystemError(error)) {
15+
if (error.code && isNonRetriableCertErrorCode(error.code)) {
16+
return false
17+
}
18+
1419
return true
1520
}
1621

17-
if (HttpError.isHttpError(error as any)) {
18-
return [408, 429, 502, 503, 504].includes((error as HttpError).status)
22+
if (HttpError.isHttpError(error)) {
23+
return [408, 429, 502, 503, 504].includes(error.status)
1924
}
2025

2126
return false
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export const NON_RETRIABLE_CERT_ERROR_CODES = Object.freeze({
2+
// The leaf certificate signature can’t be verified
3+
UNABLE_TO_VERIFY_LEAF_SIGNATURE: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
4+
// The certificate is a self-signed certificate and not in trusted root store
5+
DEPTH_ZERO_SELF_SIGNED_CERT: 'DEPTH_ZERO_SELF_SIGNED_CERT',
6+
// A self-signed certificate exists somewhere in the chain
7+
SELF_SIGNED_CERT_IN_CHAIN: 'SELF_SIGNED_CERT_IN_CHAIN',
8+
// The issuer certificate is not available locally
9+
UNABLE_TO_GET_ISSUER_CERT_LOCALLY: 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
10+
})
11+
12+
type NonRetriableCertErrorCode = typeof NON_RETRIABLE_CERT_ERROR_CODES[keyof typeof NON_RETRIABLE_CERT_ERROR_CODES]
13+
14+
export const isNonRetriableCertErrorCode = (errorCode: string | number): errorCode is NonRetriableCertErrorCode => {
15+
return Object.values(NON_RETRIABLE_CERT_ERROR_CODES).includes(errorCode as NonRetriableCertErrorCode)
16+
}

packages/server/lib/cloud/network/system_error.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export class SystemError extends Error {
77
constructor (
88
public readonly originalError: Error,
99
public readonly url: string,
10+
public readonly code: string | number | undefined,
11+
public readonly errno: string | number | undefined,
1012
) {
1113
super(originalError.message)
1214
}

0 commit comments

Comments
 (0)