@@ -11,8 +11,10 @@ const CAPTCHA_SOLVE_TIMEOUT_MS = 45_000;
1111const CAPTCHA_POLL_MS = 500 ;
1212const CAPTCHA_SETTLE_MS = 1_500 ;
1313const BLOCK_TEXT_SAMPLE_LIMIT = 4_000 ;
14- const RETRY_DELAYS_MS = [ 2_000 , 5_000 , 10_000 , 20_000 ] ;
15- const COOLDOWN_RETRY_DELAYS_MS = [ 15_000 , 30_000 , 60_000 , 90_000 ] ;
14+ const RETRY_DELAYS_MS = [ 2_000 , 5_000 , 10_000 ] ;
15+ const COOLDOWN_RETRY_DELAYS_MS = [ 15_000 , 30_000 , 60_000 ] ;
16+ const FAST_RETRY_DELAYS_MS = [ 1_000 , 3_000 , 7_000 ] ;
17+ const FAST_COOLDOWN_RETRY_DELAYS_MS = [ 0 , 2_000 , 5_000 ] ;
1618const BLOCKED_STATUS_CODES = new Set ( [ 403 , 429 , 430 , 503 , 520 , 521 , 522 , 523 ] ) ;
1719const HARD_BLOCK_PATTERNS = [
1820 "captcha" ,
@@ -47,11 +49,19 @@ type RetryNavigationOptions = {
4749 attempt : number ;
4850 delayMs : number ;
4951 error : string ;
52+ cause : unknown ;
5053 } ) => void | Promise < void > ;
54+ signal ?: AbortSignal ;
55+ getRetryDelays ?: ( error : unknown ) => number [ ] ;
5156} ;
5257
5358export type NavigateWithRecoveryOptions = RetryNavigationOptions & {
5459 onStatus ?: ( message : string ) => void ;
60+ recoverPage ?: ( details : {
61+ attempt : number ;
62+ error : unknown ;
63+ page : Page ;
64+ } ) => Promise < Page > ;
5565} ;
5666
5767type CaptchaCounts = {
@@ -79,6 +89,13 @@ export class RetryableNavigationError extends Error {
7989 }
8090}
8191
92+ export class NavigationAbortedError extends Error {
93+ constructor ( message = "Navigation aborted." ) {
94+ super ( message ) ;
95+ this . name = "NavigationAbortedError" ;
96+ }
97+ }
98+
8299export async function installSingleTabNavigation (
83100 context : BrowserContext ,
84101) : Promise < void > {
@@ -126,8 +143,37 @@ export function applyNavigationTimeouts(
126143 page . setDefaultNavigationTimeout ( NAVIGATION_TIMEOUT_MS ) ;
127144}
128145
129- function sleep ( ms : number ) : Promise < void > {
130- return new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ;
146+ function throwIfAborted ( signal ?: AbortSignal ) : void {
147+ if ( signal ?. aborted ) {
148+ throw new NavigationAbortedError ( ) ;
149+ }
150+ }
151+
152+ function sleep ( ms : number , signal ?: AbortSignal ) : Promise < void > {
153+ if ( ms <= 0 ) {
154+ throwIfAborted ( signal ) ;
155+ return Promise . resolve ( ) ;
156+ }
157+
158+ return new Promise ( ( resolve , reject ) => {
159+ const timeoutId = setTimeout ( ( ) => {
160+ signal ?. removeEventListener ( "abort" , onAbort ) ;
161+ resolve ( ) ;
162+ } , ms ) ;
163+
164+ const onAbort = ( ) => {
165+ clearTimeout ( timeoutId ) ;
166+ signal ?. removeEventListener ( "abort" , onAbort ) ;
167+ reject ( new NavigationAbortedError ( ) ) ;
168+ } ;
169+
170+ if ( signal ?. aborted ) {
171+ onAbort ( ) ;
172+ return ;
173+ }
174+
175+ signal ?. addEventListener ( "abort" , onAbort , { once : true } ) ;
176+ } ) ;
131177}
132178
133179function cloneCounts ( counts : CaptchaCounts ) : CaptchaCounts {
@@ -181,6 +227,10 @@ async function ensureCaptchaTracker(page: Page): Promise<CaptchaTracker> {
181227}
182228
183229function isRetryableNavigationError ( error : unknown ) : boolean {
230+ if ( error instanceof NavigationAbortedError ) {
231+ return false ;
232+ }
233+
184234 if ( error instanceof RetryableNavigationError ) {
185235 return true ;
186236 }
@@ -192,6 +242,8 @@ function isRetryableNavigationError(error: unknown): boolean {
192242 msg . includes ( "no_peers" ) ||
193243 msg . includes ( "ERR_CONNECTION_RESET" ) ||
194244 msg . includes ( "ERR_CONNECTION_CLOSED" ) ||
245+ msg . includes ( "ERR_TUNNEL_CONNECTION_FAILED" ) ||
246+ msg . includes ( "ERR_PROXY_CONNECTION_FAILED" ) ||
195247 msg . includes ( "Target closed" ) ||
196248 msg . includes ( "Session closed" )
197249 ) ;
@@ -212,6 +264,13 @@ function getRetryDelaysForError(error: unknown): number[] {
212264 return RETRY_DELAYS_MS ;
213265}
214266
267+ export function isNavigationAbortedError ( error : unknown ) : boolean {
268+ return (
269+ error instanceof NavigationAbortedError ||
270+ ( error instanceof Error && error . name === "NavigationAbortedError" )
271+ ) ;
272+ }
273+
215274function describeBlockIndicators ( text : string ) : string | null {
216275 const hardMatches = HARD_BLOCK_PATTERNS . filter ( ( pattern ) =>
217276 text . includes ( pattern ) ,
@@ -234,7 +293,9 @@ async function waitForCaptchaOutcome(
234293 tracker : CaptchaTracker ,
235294 baseline : CaptchaCounts ,
236295 onStatus ?: ( message : string ) => void ,
296+ signal ?: AbortSignal ,
237297) : Promise < void > {
298+ throwIfAborted ( signal ) ;
238299 const initial = tracker . snapshot ( ) ;
239300 if ( initial . detected <= baseline . detected ) {
240301 return ;
@@ -244,6 +305,7 @@ async function waitForCaptchaOutcome(
244305
245306 const startedAt = Date . now ( ) ;
246307 while ( Date . now ( ) - startedAt < CAPTCHA_SOLVE_TIMEOUT_MS ) {
308+ throwIfAborted ( signal ) ;
247309 const current = tracker . snapshot ( ) ;
248310
249311 if ( current . solveFailed > baseline . solveFailed ) {
@@ -254,11 +316,11 @@ async function waitForCaptchaOutcome(
254316
255317 if ( current . solveFinished > baseline . solveFinished ) {
256318 onStatus ?.( "Bright Data captcha solved. Validating page state." ) ;
257- await sleep ( CAPTCHA_SETTLE_MS ) ;
319+ await sleep ( CAPTCHA_SETTLE_MS , signal ) ;
258320 return ;
259321 }
260322
261- await sleep ( CAPTCHA_POLL_MS ) ;
323+ await sleep ( CAPTCHA_POLL_MS , signal ) ;
262324 }
263325
264326 throw new RetryableNavigationError (
@@ -307,10 +369,15 @@ export async function retryNavigation<T>(
307369 typeof options === "number" ? { retries : options } : options ;
308370
309371 for ( let attempt = 0 ; ; attempt ++ ) {
372+ throwIfAborted ( resolvedOptions ?. signal ) ;
373+
310374 try {
311375 return await fn ( ) ;
312376 } catch ( error ) {
313- const retryDelays = getRetryDelaysForError ( error ) ;
377+ throwIfAborted ( resolvedOptions ?. signal ) ;
378+
379+ const retryDelays =
380+ resolvedOptions ?. getRetryDelays ?.( error ) ?? getRetryDelaysForError ( error ) ;
314381 const retries = resolvedOptions ?. retries ?? retryDelays . length ;
315382 if ( attempt >= retries || ! isRetryableNavigationError ( error ) ) {
316383 throw error ;
@@ -320,8 +387,9 @@ export async function retryNavigation<T>(
320387 attempt,
321388 delayMs : delay ,
322389 error : error instanceof Error ? error . message : String ( error ) ,
390+ cause : error ,
323391 } ) ;
324- await sleep ( delay ) ;
392+ await sleep ( delay , resolvedOptions ?. signal ) ;
325393 }
326394 }
327395}
@@ -331,22 +399,55 @@ export async function navigateWithRecovery(
331399 navigate : ( ) => Promise < PlaywrightResponse | null > ,
332400 options ?: NavigateWithRecoveryOptions ,
333401) : Promise < PlaywrightResponse | null > {
334- const tracker = await ensureCaptchaTracker ( page ) ;
402+ let activePage = page ;
335403
336404 return retryNavigation (
337405 async ( ) => {
406+ const tracker = await ensureCaptchaTracker ( activePage ) ;
338407 const baseline = tracker . snapshot ( ) ;
339408 const response = await navigate ( ) ;
340- await waitForCaptchaOutcome ( tracker , baseline , options ?. onStatus ) ;
341- await assertPageIsNotBlocked ( page , response ) ;
409+ await waitForCaptchaOutcome (
410+ tracker ,
411+ baseline ,
412+ options ?. onStatus ,
413+ options ?. signal ,
414+ ) ;
415+ await assertPageIsNotBlocked ( activePage , response ) ;
342416 return response ;
343417 } ,
344418 {
345419 retries : options ?. retries ,
346- onRetry : async ( { attempt, delayMs, error } ) => {
347- await options ?. onRetry ?.( { attempt, delayMs, error } ) ;
420+ signal : options ?. signal ,
421+ getRetryDelays : ( error ) =>
422+ options ?. getRetryDelays ?.( error ) ??
423+ ( options ?. recoverPage
424+ ? isCooldownNavigationError ( error )
425+ ? FAST_COOLDOWN_RETRY_DELAYS_MS
426+ : FAST_RETRY_DELAYS_MS
427+ : getRetryDelaysForError ( error ) ) ,
428+ onRetry : async ( { attempt, delayMs, error, cause } ) => {
429+ let usedFreshSession = false ;
430+
431+ if ( options ?. recoverPage ) {
432+ activePage = await options . recoverPage ( {
433+ attempt,
434+ error : cause ,
435+ page : activePage ,
436+ } ) ;
437+ usedFreshSession = true ;
438+ }
439+
440+ await options ?. onRetry ?.( { attempt, delayMs, error, cause } ) ;
441+
442+ const retryTiming =
443+ delayMs > 0
444+ ? `Retrying in ${ Math . ceil ( delayMs / 1000 ) } s.`
445+ : "Retrying now." ;
446+ const recoveryNote = usedFreshSession
447+ ? " Opened a fresh browser session."
448+ : "" ;
348449 options ?. onStatus ?.(
349- `Navigation attempt ${ attempt + 1 } failed: ${ error } Retrying in ${ Math . ceil ( delayMs / 1000 ) } s.` ,
450+ `Navigation attempt ${ attempt + 1 } failed: ${ error } . ${ recoveryNote } ${ retryTiming } ` . trim ( ) ,
350451 ) ;
351452 } ,
352453 } ,
0 commit comments