Skip to content

Commit 685d5fc

Browse files
committed
fix: browser edge cases
1 parent 32438e2 commit 685d5fc

4 files changed

Lines changed: 240 additions & 36 deletions

File tree

src/app/api/search/route.ts

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import { checkRateLimit, getClientIp } from "@/lib/rate-limit";
1111

1212
export const runtime = "nodejs";
1313
export const dynamic = "force-dynamic";
14+
export const maxDuration = 300;
15+
16+
const AGENT_TIMEOUT_MS = 250_0000;
17+
const MAX_RETRIES = 1;
1418

1519
type SearchRequestBody = {
1620
query?: unknown;
@@ -171,36 +175,92 @@ export async function POST(request: Request): Promise<Response> {
171175
await clearAgentRuntimeSession();
172176

173177
const initialState = createInitialAgentState(query);
174-
const finalState = await runWithAgentEventEmitter(
175-
forwardAgentEvent,
176-
async () => runTicketHunterAgent(initialState),
177-
);
178178

179-
const hasTickets = finalState.tickets.length > 0;
180-
const hasAnswer = Boolean(finalState.finalAnswer);
179+
let finalState: typeof initialState;
180+
let timedOut = false;
181+
let attempt = 0;
182+
183+
const runAgentAttempt = async (): Promise<typeof initialState> => {
184+
return Promise.race([
185+
runWithAgentEventEmitter(
186+
forwardAgentEvent,
187+
async () => runTicketHunterAgent(createInitialAgentState(query)),
188+
),
189+
new Promise<never>((_, reject) =>
190+
setTimeout(
191+
() => reject(new Error("__AGENT_TIMEOUT__")),
192+
AGENT_TIMEOUT_MS,
193+
),
194+
),
195+
]);
196+
};
197+
198+
while (attempt <= MAX_RETRIES) {
199+
try {
200+
finalState = await runAgentAttempt();
201+
} catch (raceError) {
202+
const isTimeout =
203+
raceError instanceof Error &&
204+
raceError.message === "__AGENT_TIMEOUT__";
205+
if (!isTimeout) throw raceError;
206+
207+
timedOut = true;
208+
log(
209+
"warn",
210+
`Agent timed out after ${AGENT_TIMEOUT_MS / 1000}s (attempt ${attempt + 1}). Returning partial results.`,
211+
);
212+
finalState = {
213+
...initialState,
214+
error: "Search timed out. Returning partial results.",
215+
};
216+
}
217+
218+
const hasResults = finalState!.tickets.length > 0 || Boolean(finalState!.finalAnswer);
219+
220+
if (hasResults || attempt >= MAX_RETRIES || timedOut) {
221+
break;
222+
}
223+
224+
attempt++;
225+
log("info", `No results on attempt ${attempt}. Retrying search...`);
226+
safelySend({
227+
type: "status",
228+
message: `No results found, retrying search (attempt ${attempt + 1})...`,
229+
});
230+
await clearAgentRuntimeSession();
231+
}
232+
233+
const hasTickets = finalState!.tickets.length > 0;
234+
const hasAnswer = Boolean(finalState!.finalAnswer);
181235

182236
if (hasTickets || hasAnswer) {
183237
safelySend({
184238
type: "result",
185-
tickets: finalState.tickets,
186-
finalAnswer: finalState.finalAnswer,
239+
tickets: finalState!.tickets,
240+
finalAnswer: finalState!.finalAnswer,
187241
});
188242
}
189243

190-
if (finalState.error) {
244+
if (timedOut && !hasTickets && !hasAnswer) {
245+
safelySend({
246+
type: "error",
247+
message:
248+
"Search took too long and no results were collected. Please try again.",
249+
});
250+
} else if (finalState!.error && !timedOut) {
191251
log("warn", "Agent completed with error.", {
192-
error: finalState.error,
193-
stepCount: finalState.stepCount,
252+
error: finalState!.error,
253+
stepCount: finalState!.stepCount,
194254
hadResults: hasTickets || hasAnswer,
195255
});
196256
if (!hasTickets && !hasAnswer && !sentError) {
197-
safelySend({ type: "error", message: finalState.error });
257+
safelySend({ type: "error", message: finalState!.error });
198258
}
199259
} else {
200260
log("info", "Agent completed successfully.", {
201-
ticketCount: finalState.tickets.length,
202-
stepCount: finalState.stepCount,
203-
hasInspectUrl: Boolean(finalState.inspectUrl),
261+
ticketCount: finalState!.tickets.length,
262+
stepCount: finalState!.stepCount,
263+
hasInspectUrl: Boolean(finalState!.inspectUrl),
204264
});
205265
}
206266
} catch (error) {

src/lib/agent/browser-utils.ts

Lines changed: 115 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ const CAPTCHA_SOLVE_TIMEOUT_MS = 45_000;
1111
const CAPTCHA_POLL_MS = 500;
1212
const CAPTCHA_SETTLE_MS = 1_500;
1313
const 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];
1618
const BLOCKED_STATUS_CODES = new Set([403, 429, 430, 503, 520, 521, 522, 523]);
1719
const 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

5358
export 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

5767
type 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+
8299
export 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

133179
function cloneCounts(counts: CaptchaCounts): CaptchaCounts {
@@ -181,6 +227,10 @@ async function ensureCaptchaTracker(page: Page): Promise<CaptchaTracker> {
181227
}
182228

183229
function 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+
215274
function 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

Comments
 (0)