From 687fd54d8399abc39284d376f470ce4ee355768d Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:24:01 -0500 Subject: [PATCH] Retry browser quick-execute on expired session --- package.json | 2 +- src/__tests__/commands/browser.test.ts | 89 +++++++++++++++++ src/commands/browser.ts | 131 ++++++++++++++++++------- 3 files changed, 184 insertions(+), 38 deletions(-) diff --git a/package.json b/package.json index dc6b716baa..026c386f53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.9.0", + "version": "1.9.1", "description": "Command-line interface for Firecrawl. Scrape, crawl, and extract data from any website directly from your terminal.", "main": "dist/index.js", "bin": { diff --git a/src/__tests__/commands/browser.test.ts b/src/__tests__/commands/browser.test.ts index d751c84069..b0daaec017 100644 --- a/src/__tests__/commands/browser.test.ts +++ b/src/__tests__/commands/browser.test.ts @@ -163,4 +163,93 @@ describe('Browser Commands', () => { expect.objectContaining({ id: 'new-session' }) ); }); + + it('quick execute retries with new session on 403 Forbidden', async () => { + const { clearBrowserSession, saveBrowserSession } = + await import('../../utils/browser-session'); + + // First execute call fails with 403 (stale cached session) + const forbiddenError = Object.assign(new Error('Forbidden'), { + status: 403, + }); + mockClient.browserExecute + .mockRejectedValueOnce(forbiddenError) + .mockResolvedValueOnce({ + success: true, + stdout: 'page loaded', + }); + + // Launch new session succeeds + mockClient.browser.mockResolvedValue({ + success: true, + id: 'fresh-session', + cdpUrl: 'wss://fresh', + }); + + await handleBrowserQuickExecute({ code: 'open https://example.com' }); + + expect(clearBrowserSession).toHaveBeenCalled(); + expect(mockClient.browser).toHaveBeenCalledTimes(1); + expect(saveBrowserSession).toHaveBeenCalledWith( + expect.objectContaining({ id: 'fresh-session' }) + ); + expect(mockClient.browserExecute).toHaveBeenCalledTimes(2); + }); + + it('quick execute retries with new session on 410 Gone', async () => { + const { clearBrowserSession } = await import('../../utils/browser-session'); + + const goneError = Object.assign(new Error('Gone'), { status: 410 }); + mockClient.browserExecute + .mockRejectedValueOnce(goneError) + .mockResolvedValueOnce({ + success: true, + stdout: 'page loaded', + }); + + mockClient.browser.mockResolvedValue({ + success: true, + id: 'fresh-session', + cdpUrl: 'wss://fresh', + }); + + await handleBrowserQuickExecute({ code: 'open https://example.com' }); + + expect(clearBrowserSession).toHaveBeenCalled(); + expect(mockClient.browser).toHaveBeenCalledTimes(1); + expect(mockClient.browserExecute).toHaveBeenCalledTimes(2); + }); + + it('quick execute retries on "session destroyed" error message', async () => { + const { clearBrowserSession } = await import('../../utils/browser-session'); + + mockClient.browserExecute + .mockRejectedValueOnce(new Error('session has been destroyed')) + .mockResolvedValueOnce({ + success: true, + stdout: 'ok', + }); + + mockClient.browser.mockResolvedValue({ + success: true, + id: 'fresh-session', + cdpUrl: 'wss://fresh', + }); + + await handleBrowserQuickExecute({ code: 'open https://example.com' }); + + expect(clearBrowserSession).toHaveBeenCalled(); + expect(mockClient.browserExecute).toHaveBeenCalledTimes(2); + }); + + it('quick execute exits on non-session errors without retry', async () => { + mockClient.browserExecute.mockRejectedValueOnce( + new Error('Network timeout') + ); + + await handleBrowserQuickExecute({ code: 'open https://example.com' }); + + expect(mockClient.browser).not.toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(1); + }); }); diff --git a/src/commands/browser.ts b/src/commands/browser.ts index c8fb1110b5..3e4cf21954 100644 --- a/src/commands/browser.ts +++ b/src/commands/browser.ts @@ -165,7 +165,7 @@ function isSessionExpiredError(error: unknown): boolean { ? error.message.toLowerCase() : String(error).toLowerCase(); const status = (error as { status?: number }).status; - if (status === 410 || status === 404) return true; + if (status === 403 || status === 410 || status === 404) return true; return /destroyed|expired|not found|gone|session.*closed/i.test(msg); } @@ -282,42 +282,44 @@ export async function handleBrowserList( export async function handleBrowserQuickExecute( options: BrowserQuickExecuteOptions ): Promise { - // Auto-launch if no active session - const existing = loadBrowserSession(); - if (!existing) { - try { - const app = getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl }); - - const launchArgs: { - profile?: { - name: string; - saveChanges?: boolean; - }; - } = {}; - if (options.profile) { - launchArgs.profile = { - name: options.profile, - saveChanges: options.saveChanges, - }; - } + async function launchNewSession(): Promise { + const app = getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl }); - const data = await app.browser( - launchArgs as Parameters[0] - ); + const launchArgs: { + profile?: { + name: string; + saveChanges?: boolean; + }; + } = {}; + if (options.profile) { + launchArgs.profile = { + name: options.profile, + saveChanges: options.saveChanges, + }; + } - if (!data.success) { - console.error('Error:', data.error || 'Failed to launch session'); - process.exit(1); - } + const data = await app.browser( + launchArgs as Parameters[0] + ); - saveBrowserSession({ - id: data.id!, - cdpUrl: data.cdpUrl!, - createdAt: new Date().toISOString(), - }); + if (!data.success) { + console.error('Error:', data.error || 'Failed to launch session'); + process.exit(1); + } + + saveBrowserSession({ + id: data.id!, + cdpUrl: data.cdpUrl!, + createdAt: new Date().toISOString(), + }); + + console.error(`Session launched: ${data.id}`); + } - // Print session info to stderr so it doesn't mix with command output - console.error(`Session launched: ${data.id}`); + // Auto-launch if no active session + if (!loadBrowserSession()) { + try { + await launchNewSession(); } catch (error) { console.error( 'Error:', @@ -333,15 +335,70 @@ export async function handleBrowserQuickExecute( finalCode = `agent-browser ${finalCode}`; } - // Execute via the standard handler - await handleBrowserExecute({ + const executeOptions = { code: finalCode, - language: 'bash', + language: 'bash' as const, apiKey: options.apiKey, apiUrl: options.apiUrl, output: options.output, json: options.json, - }); + }; + + // Try executing; if the cached session is stale, launch a new one and retry + try { + const sessionId = getSessionId(); + const app = getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl }); + + const data = await app.browserExecute(sessionId, { + code: executeOptions.code, + language: executeOptions.language, + }); + + if (!data.success) { + console.error('Error:', data.error || 'Unknown error'); + process.exit(1); + } + + if (data.error) { + process.stderr.write(`Code error: ${data.error}\n`); + } + + if (options.json) { + const output = JSON.stringify(data, null, 2); + writeOutput(output, options.output, !!options.output); + } else { + const result = data.stdout || data.result || ''; + if (result) { + writeOutput(result.trimEnd(), options.output, !!options.output); + } + } + } catch (error) { + if (isSessionExpiredError(error)) { + console.error('Cached session expired, launching a new one...'); + clearBrowserSession(); + + try { + await launchNewSession(); + } catch (launchError) { + console.error( + 'Error:', + launchError instanceof Error + ? launchError.message + : 'Failed to launch session' + ); + process.exit(1); + } + + // Retry with the new session + await handleBrowserExecute(executeOptions); + } else { + console.error( + 'Error:', + error instanceof Error ? error.message : 'Unknown error occurred' + ); + process.exit(1); + } + } } /**