diff --git a/package-lock.json b/package-lock.json index e92bd3c..6f61052 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2974,9 +2974,9 @@ } }, "node_modules/hono": { - "version": "4.12.23", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", - "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "version": "4.12.26", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz", + "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -4864,9 +4864,9 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/package.json b/package.json index b32996d..58cc402 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,8 @@ "minimatch": "^10.2.1", "glob": "^11.0.0", "serialize-javascript": "^7.0.5" - } + }, + "hono": "^4.12.26", + "undici": "^7.28.0" } } diff --git a/src/lib/agent-client.ts b/src/lib/agent-client.ts index f4bd570..bb55e03 100644 --- a/src/lib/agent-client.ts +++ b/src/lib/agent-client.ts @@ -144,9 +144,10 @@ export class ProfileNotFoundError extends UpgradeError { } // Upgrade statuses where a one-shot retry cannot help: bad request (400), -// bad auth (401), forbidden by plan/policy (403), or missing resource (404). -// Retrying just wastes time and emits a misleading "second attempt failed". -const NON_RETRYABLE_UPGRADE_STATUSES = new Set([400, 401, 403, 404]); +// bad auth (401), forbidden by plan/policy (403), missing resource (404), or +// concurrency limit (429). Retrying a 429 just opens another session and +// stacks more lingering sessions against the same limit, so stop instead. +const NON_RETRYABLE_UPGRADE_STATUSES = new Set([400, 401, 403, 404, 429]); export const isRetryableUpgradeError = (err: unknown): boolean => { if (err instanceof UpgradeError) { diff --git a/src/lib/agent-format.ts b/src/lib/agent-format.ts index b1ff2dd..4f79831 100644 --- a/src/lib/agent-format.ts +++ b/src/lib/agent-format.ts @@ -103,7 +103,7 @@ export const formatConnectError = (err: unknown): string => { case 403: return `Forbidden (403) — your plan does not include this feature${detail ? ` (server says: ${detail})` : ''}.`; case 429: - return `Concurrency limit reached (429)${detail ? `: ${detail}` : ''}. Wait for in-flight sessions to finish, or upgrade the plan.`; + return `Concurrency limit reached (429)${detail ? `: ${detail}` : ''}. Stop retrying — each new attempt opens another session and stacks more against the limit. Close any sessions you still have open (call browserless_agent with method "close"), wait for in-flight sessions to finish, or upgrade the plan, then start over.`; default: { const fallback = detail || err.statusMessage || ''; return `Failed to connect to browser agent (HTTP ${err.statusCode})${fallback ? `: ${fallback}` : ''}.`; diff --git a/test/lib/agent-client.spec.ts b/test/lib/agent-client.spec.ts index 3836aa3..c4682d2 100644 --- a/test/lib/agent-client.spec.ts +++ b/test/lib/agent-client.spec.ts @@ -234,8 +234,10 @@ describe('agent-client proxyFingerprint', () => { describe('agent-client isRetryableUpgradeError', () => { // The retry guard exists so the agent tool doesn't burn a second WS // handshake when the server already returned a definitive 4xx. - it('does not retry on 400/401/403/404', () => { - for (const status of [400, 401, 403, 404]) { + it('does not retry on 400/401/403/404/429', () => { + // 429 is non-retryable: a retry opens another session and stacks more + // lingering sessions against the same concurrency limit. + for (const status of [400, 401, 403, 404, 429]) { expect( isRetryableUpgradeError(new UpgradeError(status, 'msg', 'body')), `status=${status}`, @@ -243,8 +245,8 @@ describe('agent-client isRetryableUpgradeError', () => { } }); - it('retries on 5xx and 429 (transient)', () => { - for (const status of [429, 500, 502, 503]) { + it('retries on 5xx (transient)', () => { + for (const status of [500, 502, 503]) { expect( isRetryableUpgradeError(new UpgradeError(status, 'msg', 'body')), `status=${status}`, diff --git a/test/tools/agent.spec.ts b/test/tools/agent.spec.ts index bac8dfb..88f0183 100644 --- a/test/tools/agent.spec.ts +++ b/test/tools/agent.spec.ts @@ -394,7 +394,8 @@ describe('formatConnectError', () => { ), ); expect(out).to.match(/^Concurrency limit reached \(429\)/); - expect(out).to.include('Wait for'); + expect(out).to.include('Stop retrying'); + expect(out).to.include('wait for'); }); it('falls back to a generic upgrade message for unrecognized statuses', () => { @@ -461,7 +462,7 @@ describe('formatConnectError with proxy-injected errors', () => { const out = formatConnectError( new UpgradeError(429, 'Too Many Requests', ''), ); - expect(out).to.match(/^Concurrency limit reached \(429\)\. Wait for/); + expect(out).to.match(/^Concurrency limit reached \(429\)\. Stop retrying/); }); it('renders nginx HTML 502 body as cleaned text', () => {