import logger from '@wdio/logger' import { sleep } from '@wdio/utils' import type { Options } from '@wdio/types' import { type RequestInit as UndiciRequestInit } from 'undici' import { WebDriverResponseError, WebDriverRequestError } from './error.js' import { RETRYABLE_STATUS_CODES, RETRYABLE_ERROR_CODES } from './constants.js' import type { WebDriverResponse, RequestLibResponse, RequestOptions, RequestEventHandler } from './types.js' import { isSuccessfulResponse } from '../utils.js' import { DEFAULTS } from '../constants.js' import pkg from '../../package.json' with { type: 'json' } import './polyfill.js' const ERRORS_TO_EXCLUDE_FROM_RETRY = [ 'detached shadow root', 'move target out of bounds' ] const DEFAULT_HEADERS = { 'Content-Type': 'application/json; charset=utf-8', 'Connection': 'keep-alive', 'Accept': 'application/json', 'User-Agent': 'webdriver/' + pkg.version } const log = logger('webdriver') export abstract class WebDriverRequest { protected abstract fetch(url: URL, opts: RequestInit): Promise body?: Record method: string endpoint: string isHubCommand: boolean requiresSessionId: boolean eventHandler: RequestEventHandler abortSignal?: AbortSignal constructor ( method: string, endpoint: string, body?: Record, abortSignal?: AbortSignal, isHubCommand: boolean = false, eventHandler: RequestEventHandler = {} ) { this.body = body this.method = method this.endpoint = endpoint this.isHubCommand = isHubCommand this.requiresSessionId = Boolean(this.endpoint.match(/:sessionId/)) this.eventHandler = eventHandler this.abortSignal = abortSignal } async makeRequest (options: RequestOptions, sessionId?: string) { const { url, requestOptions } = await this.createOptions(options, sessionId) this.eventHandler.onRequest?.(requestOptions) return this._request(url, requestOptions, options.transformResponse, options.connectionRetryCount, 0) } async createOptions (options: RequestOptions, sessionId?: string, isBrowser: boolean = false): Promise<{ url: URL; requestOptions: RequestInit; }> { const timeout = options.connectionRetryTimeout || DEFAULTS.connectionRetryTimeout.default as number const requestOptions: RequestInit = { method: this.method, redirect: 'follow', signal: AbortSignal.any([ AbortSignal.timeout(timeout), ...(this.abortSignal ? [this.abortSignal] : []) ]) } const requestHeaders: HeadersInit = new Headers({ ...DEFAULT_HEADERS, ...(typeof options.headers === 'object' ? options.headers : {}) }) const searchParams = isBrowser ? undefined : (typeof options.queryParams === 'object' ? options.queryParams : undefined) /** * only apply body property if existing */ if (this.body && (Object.keys(this.body).length || this.method === 'POST')) { const contentLength = new TextEncoder().encode(JSON.stringify(this.body)).length requestOptions.body = this.body as unknown as BodyInit requestHeaders.set('Content-Length', `${contentLength}`) } /** * if we don't have a session id we set it here, unless we call commands that don't require session ids, for * example /sessions. The call to /sessions is not connected to a session itself and it therefore doesn't * require it */ let endpoint = this.endpoint if (this.requiresSessionId) { if (!sessionId) { throw new Error('A sessionId is required for this command') } endpoint = endpoint.replace(':sessionId', sessionId) } const url = new URL(`${options.protocol}://${options.hostname}:${options.port}${this.isHubCommand ? this.endpoint : `${options.path || ''}/${endpoint}`.replace(/(\/){2,}/g, '/')}`) if (searchParams) { url.search = new URLSearchParams(searchParams).toString() } /** * send authentication credentials only when creating new session */ if (this.endpoint === '/session' && options.user && options.key) { requestHeaders.set('Authorization', 'Basic ' + btoa(options.user + ':' + options.key)) } requestOptions.headers = requestHeaders return { url, requestOptions: typeof options.transformRequest === 'function' ? options.transformRequest(requestOptions) : requestOptions } } protected async _libRequest (url: URL, opts: RequestInit): Promise { try { const dispatcher = (opts as UndiciRequestInit).dispatcher const response = await this.fetch(url, { method: opts.method, body: JSON.stringify(opts.body), headers: opts.headers as Record, signal: opts.signal, redirect: opts.redirect, ...(dispatcher ? { dispatcher } : {}) }) return { statusCode: response.status, body: await response.json() ?? {}, } satisfies Options.RequestLibResponse } catch (err) { if (!(err instanceof Error)) { throw new WebDriverRequestError( new Error(`Failed to fetch ${url.href}: ${(err as Error).message || err || 'Unknown error'}`), url, opts ) } throw new WebDriverRequestError(err, url, opts) } } protected async _request ( url: URL, fullRequestOptions: RequestInit, transformResponse?: (response: RequestLibResponse, requestOptions: RequestInit) => RequestLibResponse, totalRetryCount = 0, retryCount = 0 ): Promise { log.info(`[${fullRequestOptions.method}] ${(url as URL).href}`) if (fullRequestOptions.body && Object.keys(fullRequestOptions.body).length) { this.eventHandler.onLogData?.(fullRequestOptions.body) } const { ...requestLibOptions } = fullRequestOptions const startTime = performance.now() let response = await this._libRequest(url!, requestLibOptions) .catch((err: WebDriverRequestError) => err) const durationMillisecond = performance.now() - startTime /** * handle retries for requests * @param {Error} error error object that causes the retry * @param {string} msg message that is being shown as warning to user */ const retry = async (error: Error) => { /** * stop retrying if totalRetryCount was exceeded or there is no reason to * retry, e.g. if sessionId is invalid */ if (retryCount >= totalRetryCount || error.message.includes('invalid session id')) { log.error(error.message) this.eventHandler.onResponse?.({ error }) this.eventHandler.onPerformance?.({ request: fullRequestOptions, durationMillisecond, success: false, error, retryCount }) throw error } if (retryCount > 0) { /* * Exponential backoff with a minimum of 500ms and a maximum of 10_000ms. */ await sleep(Math.min(10000, 250 * Math.pow(2, retryCount))) } ++retryCount this.eventHandler.onRetry?.({ error, retryCount }) this.eventHandler.onPerformance?.({ request: fullRequestOptions, durationMillisecond, success: false, error, retryCount }) log.warn(error.message) log.info(`Retrying ${retryCount}/${totalRetryCount}`) return this._request(url, fullRequestOptions, transformResponse, totalRetryCount, retryCount) } /** * handle request errors */ if (response instanceof Error) { const resError = response as WebDriverRequestError /** * retry failed requests, only if: * - the abort signal is not aborted * - the error code or status code is retryable */ if ( !(this.abortSignal && this.abortSignal.aborted) && ( (resError.code && RETRYABLE_ERROR_CODES.includes(resError.code)) || (resError.statusCode && RETRYABLE_STATUS_CODES.includes(resError.statusCode)) ) ) { return retry(resError) } /** * throw if request error is unknown */ this.eventHandler.onPerformance?.({ request: fullRequestOptions, durationMillisecond, success: false, error: response, retryCount }) throw response } if (typeof transformResponse === 'function') { response = transformResponse(response, fullRequestOptions) as RequestLibResponse } /** * Resolve only if successful response */ if (isSuccessfulResponse(response.statusCode, response.body)) { this.eventHandler.onResponse?.({ result: response.body }) this.eventHandler.onPerformance?.({ request: fullRequestOptions, durationMillisecond, success: true, retryCount }) return response.body as WebDriverResponse } const error = new WebDriverResponseError(response, url, fullRequestOptions) /** * hub commands don't follow standard response formats * and can have empty bodies */ if (this.isHubCommand) { /** * if body contains HTML the command was called on a node * directly without using a hub, therefore throw */ if (typeof response.body === 'string' && response.body.startsWith('')) { this.eventHandler.onPerformance?.({ request: fullRequestOptions, durationMillisecond, success: false, error, retryCount }) return Promise.reject(new Error('Command can only be called to a Selenium Hub')) } return { value: response.body || null } } /** * stop retrying as this will never be successful. * we will handle this at the elementErrorHandler */ if (error.name === 'stale element reference') { log.warn('Request encountered a stale element - terminating request') this.eventHandler.onResponse?.({ error }) this.eventHandler.onPerformance?.({ request: fullRequestOptions, durationMillisecond, success: false, error, retryCount }) throw error } /** * some errors can be excluded from the request retry mechanism as * it likely does not changes anything and the error is handled within the command. */ if (ERRORS_TO_EXCLUDE_FROM_RETRY.includes(error.name)) { throw error } return retry(error) } }