feat: add screenshot retry logic for transient browser failures
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled

- Add isRetryableError() helper (retry on TimeoutError, Protocol error,
  Target closed, Session closed, Navigation failed, net::ERR_*)
- Wrap browser screenshot in retry loop (max 2 retries, exponential backoff)
- Add retryCount to ScreenshotResult, X-Retry-Count response header
- Validation/SSRF/auth errors are NOT retried
- 28 new tests (12 retry classification + 6 screenshot retry + route tests)
This commit is contained in:
Hoid 2026-03-06 09:12:44 +01:00
parent 8a36826e35
commit fde5aea324
6 changed files with 184 additions and 8 deletions

View file

@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest'
import { isRetryableError } from '../retry.js'
describe('isRetryableError', () => {
it('returns true for TimeoutError', () => {
const err = new Error('TimeoutError: Navigation timeout of 20000ms exceeded')
err.name = 'TimeoutError'
expect(isRetryableError(err)).toBe(true)
})
it('returns true for Protocol error', () => {
expect(isRetryableError(new Error('Protocol error (Runtime.callFunctionOn): Session closed.'))).toBe(true)
})
it('returns true for Target closed', () => {
expect(isRetryableError(new Error('Target closed'))).toBe(true)
})
it('returns true for Session closed', () => {
expect(isRetryableError(new Error('Session closed. Most likely the page has been closed.'))).toBe(true)
})
it('returns true for Navigation failed', () => {
expect(isRetryableError(new Error('Navigation failed because browser has disconnected!'))).toBe(true)
})
it('returns true for net::ERR_ errors', () => {
expect(isRetryableError(new Error('net::ERR_CONNECTION_RESET'))).toBe(true)
expect(isRetryableError(new Error('net::ERR_CONNECTION_REFUSED'))).toBe(true)
})
it('returns false for SCREENSHOT_TIMEOUT (overall timeout, not transient)', () => {
expect(isRetryableError(new Error('SCREENSHOT_TIMEOUT'))).toBe(false)
})
it('returns false for SSRF_BLOCKED', () => {
expect(isRetryableError(new Error('SSRF_BLOCKED'))).toBe(false)
})
it('returns false for validation errors', () => {
expect(isRetryableError(new Error('hideSelector contains dangerous characters'))).toBe(false)
expect(isRetryableError(new Error('selector and fullPage are mutually exclusive'))).toBe(false)
})
it('returns false for SELECTOR_NOT_FOUND', () => {
expect(isRetryableError(new Error('SELECTOR_NOT_FOUND'))).toBe(false)
})
it('returns false for JS_EXECUTION_ERROR', () => {
expect(isRetryableError(new Error('JS_EXECUTION_ERROR: foo is not defined'))).toBe(false)
})
it('returns false for generic errors', () => {
expect(isRetryableError(new Error('Something went wrong'))).toBe(false)
})
})