Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ task publish # npm + pypi publish

- **Every method with a request body MUST accept `**extra`** (Python) or spread extra fields (TS) for forward-compatibility. No exceptions.
- **Both SDKs must stay in sync.** Same retry logic, same polling defaults (2s interval, 300s timeout), same terminal statuses, same backoff cap (10s). When changing behavior in one SDK, change both.
- **Only retry HTTP 429.** Do not retry 5xx errors.
- **Retry HTTP 429 for all methods, and 502/503/504 for GET only.** Honor integer-second `Retry-After` within the existing ten-second delay cap; surface longer waits and use normal backoff for other formats. Keep the retry count and up to 250ms jitter; timeout remains per attempt. Do not retry other 5xx or transport failures.
- **Every public method needs a docstring** (Python) or JSDoc (TS). One-liner, imperative style.
- **Re-export all user-facing types** from top-level `__init__.py` / `index.ts`. Users should never need to import from `generated/` directly.

Expand Down
44 changes: 38 additions & 6 deletions browser-use-node/src/core/http.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,34 @@
import { BrowserUseError } from "./errors.js";
import type { FetchLike } from "./x402.js";

const MAX_RETRY_DELAY = 10_000;

/** Honor Cloud's integer-second Retry-After; other formats use normal backoff. */
function retryDelay(response: Response, attempt: number): number | undefined {
const value = response.headers.get("Retry-After")?.trim();
const retryAfter = value && /^\d+$/.test(value) ? Number(value) * 1000 : undefined;
// Timeout remains per attempt; preserve the existing ten-second delay cap.
if (retryAfter !== undefined && retryAfter > MAX_RETRY_DELAY) return undefined;

const backoff = Math.min(1000 * 2 ** attempt, MAX_RETRY_DELAY);
return Math.min(Math.max(backoff, retryAfter ?? 0) + Math.random() * 250, MAX_RETRY_DELAY);
}

async function sleep(delay: number, signal?: AbortSignal): Promise<void> {
signal?.throwIfAborted();
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener("abort", abort);
resolve();
}, delay);
const abort = () => {
clearTimeout(timer);
reject(signal?.reason);
};
signal?.addEventListener("abort", abort, { once: true });
});
}

export interface HttpClientOptions {
apiKey: string;
baseUrl: string;
Expand Down Expand Up @@ -76,10 +104,7 @@ export class HttpClient {
}

for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
if (attempt > 0) {
const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
options?.signal?.throwIfAborted();

const controller = new AbortController();
const timeoutId = options?.signal
Expand Down Expand Up @@ -107,11 +132,18 @@ export class HttpClient {
return (await response.json()) as T;
}

// GETs are safe to replay after a temporary upstream failure. Writes
// retain only the existing 429 retry: a 5xx may follow a successful write.
const shouldRetry =
response.status === 429 &&
(response.status === 429 ||
(method.toUpperCase() === "GET" && [502, 503, 504].includes(response.status))) &&
attempt < this.maxRetries;
const delay = shouldRetry ? retryDelay(response, attempt) : undefined;

if (shouldRetry) {
if (delay !== undefined) {
// Release the failed response before waiting or opening another request.
await response.body?.cancel().catch(() => {});
await sleep(delay, options?.signal);
continue;
}

Expand Down
195 changes: 194 additions & 1 deletion browser-use-node/tests/http.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { HttpClient } from "../src/core/http.js";
import type { FetchLike } from "../src/core/x402.js";
import { BrowserUse } from "../src/v4/client.js";

describe("HttpClient query serialization", () => {
it("repeats array query parameters", async () => {
Expand Down Expand Up @@ -52,3 +54,194 @@ describe("HttpClient query serialization", () => {
expect(requestedHeaders.get("X-Browser-Use-API-Key")).toBe("test");
});
});

function response(status: number, retryAfter?: string): Response {
return new Response(JSON.stringify({ detail: "temporary failure" }), {
status,
headers: retryAfter === undefined ? {} : { "Retry-After": retryAfter },
});
}

describe("HttpClient retries", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(Math, "random").mockReturnValue(0);
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});

function client(fetch: FetchLike, maxRetries?: number) {
return new HttpClient({ apiKey: "test", baseUrl: "https://api.example.com", fetch, maxRetries });
}

it.each([502, 503, 504])("recovers a transient GET %i", async (status) => {
const failed = response(status);
const cancel = vi.spyOn(failed.body!, "cancel");
const fetch = vi.fn<FetchLike>()
.mockResolvedValueOnce(failed)
.mockResolvedValueOnce(new Response('{"status":"completed"}'));
const pending = client(fetch).get("/runs/id/status");
await vi.advanceTimersByTimeAsync(999);
expect(fetch).toHaveBeenCalledTimes(1);
expect(cancel).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(1);
expect(await pending).toEqual({ status: "completed" });
expect(fetch).toHaveBeenCalledTimes(2);
});

it("lets the public completion helper survive temporary status and result failures", async () => {
const paths: string[] = [];
const responses = [
response(503, "2"),
new Response('{"status":"completed"}'),
response(502),
new Response('{"id":"run-id","status":"completed","result":"done"}'),
];
vi.stubGlobal("fetch", vi.fn(async (input: string) => {
paths.push(new URL(input).pathname);
return responses.shift()!;
}));
const sdk = new BrowserUse({ apiKey: "test", baseUrl: "https://api.example.com" });
const pending = sdk.runs.waitForCompletion("run-id");
await vi.advanceTimersByTimeAsync(3_000);
expect((await pending).result).toBe("done");
expect(paths).toEqual([
"/runs/run-id/status", "/runs/run-id/status", "/runs/run-id", "/runs/run-id",
]);
});

it("honors Retry-After through public browser creation", async () => {
const fetch = vi.fn<FetchLike>()
.mockResolvedValueOnce(response(429, "5"))
.mockResolvedValueOnce(new Response('{"id":"browser-id","status":"active"}'));
vi.stubGlobal("fetch", fetch);
const sdk = new BrowserUse({ apiKey: "test", baseUrl: "https://api.example.com" });
const pending = sdk.browsers.create();
await vi.advanceTimersByTimeAsync(4_999);
expect(fetch).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(1);
expect((await pending).id).toBe("browser-id");
expect(fetch).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenLastCalledWith("https://api.example.com/browsers", expect.objectContaining({ method: "POST" }));
});

it.each([429, 503])("bounds persistent %i to four total attempts by default", async (status) => {
const fetch = vi.fn<FetchLike>().mockImplementation(async () => response(status));
const pending = client(fetch).get("/runs/id/status");
const rejected = expect(pending).rejects.toMatchObject({ statusCode: status, message: "temporary failure" });
await vi.advanceTimersByTimeAsync(7_000);
await rejected;
expect(fetch).toHaveBeenCalledTimes(4);
expect(vi.getTimerCount()).toBe(0);
});

it.each([429, 503])("disables retries for %i when maxRetries is zero", async (status) => {
const fetch = vi.fn<FetchLike>().mockImplementation(async () => response(status));
await expect(client(fetch, 0).get("/runs/id/status")).rejects.toMatchObject({ statusCode: status });
expect(fetch).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});

it.each([
["5", 5_000],
[" 5 ", 5_000],
["10", 10_000],
["0", 1_000],
["", 1_000],
["garbage", 1_000],
["-1", 1_000],
["1.5", 1_000],
["Fri, 11 Sep 2026 00:00:05 GMT", 1_000],
])("uses numeric Retry-After %s or falls back to normal backoff", async (header, delay) => {
const fetch = vi.fn<FetchLike>()
.mockResolvedValueOnce(response(429, header as string))
.mockResolvedValueOnce(new Response("{}"));
const pending = client(fetch).post("/browsers", {});
await vi.advanceTimersByTimeAsync((delay as number) - 1);
expect(fetch).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await pending;
expect(fetch).toHaveBeenCalledTimes(2);
});

it.each(["11", "60", "9".repeat(400)])(
"surfaces Retry-After %s immediately when the wait exceeds the bound", async (header) => {
const fetch = vi.fn<FetchLike>().mockImplementation(async () => response(429, header));
await expect(client(fetch).post("/browsers", {})).rejects.toMatchObject({ statusCode: 429 });
expect(fetch).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
},
);

it("adds positive jitter to the server delay", async () => {
vi.mocked(Math.random).mockReturnValue(0.5);
const fetch = vi.fn<FetchLike>()
.mockResolvedValueOnce(response(429, "5"))
.mockResolvedValueOnce(new Response("{}"));
const pending = client(fetch).post("/browsers", {});
await vi.advanceTimersByTimeAsync(5_124);
expect(fetch).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(1);
await pending;
expect(fetch).toHaveBeenCalledTimes(2);
});

it.each([undefined, "0", "1"])("caps exponential backoff with Retry-After %s including jitter at ten seconds", async (header) => {
vi.mocked(Math.random).mockReturnValue(0.5);
const times: number[] = [];
const start = Date.now();
const fetch = vi.fn<FetchLike>().mockImplementation(async () => {
times.push(Date.now() - start);
return response(429, header);
});
const rejected = expect(client(fetch, 5).get("/browsers")).rejects.toMatchObject({ statusCode: 429 });
await vi.runAllTimersAsync();
await rejected;
expect(times).toEqual([0, 1_125, 3_250, 7_375, 15_500, 25_500]);
});

it.each(["POST", "PATCH", "DELETE"])("never retries ambiguous %s 5xx", async (method) => {
for (const status of [500, 502, 503, 504]) {
const fetch = vi.fn<FetchLike>().mockImplementation(async () => response(status, "2"));
await expect(client(fetch).request(method, "/browsers")).rejects.toMatchObject({ statusCode: status });
expect(fetch).toHaveBeenCalledOnce();
}
});

it.each([400, 401, 403, 404, 409, 500, 501, 505])("does not retry GET %i", async (status) => {
const fetch = vi.fn<FetchLike>().mockImplementation(async () => response(status));
await expect(client(fetch).get("/browsers")).rejects.toMatchObject({ statusCode: status });
expect(fetch).toHaveBeenCalledOnce();
});

it.each(["GET", "POST"])("does not retry %s transport failures", async (method) => {
const failure = new TypeError("fetch failed");
const fetch = vi.fn<FetchLike>().mockRejectedValue(failure);
await expect(client(fetch).request(method, "/browsers")).rejects.toBe(failure);
expect(fetch).toHaveBeenCalledOnce();
});

it("aborts during backoff without sending another request or leaving a timer", async () => {
const controller = new AbortController();
const fetch = vi.fn<FetchLike>().mockImplementation(async () => response(503, "10"));
const pending = client(fetch).request("GET", "/browsers", { signal: controller.signal });
const rejected = expect(pending).rejects.toMatchObject({ name: "AbortError" });
await vi.advanceTimersByTimeAsync(100);
controller.abort();
await rejected;
await vi.advanceTimersByTimeAsync(10_000);
expect(fetch).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});

it("does not send a request when its signal is already aborted", async () => {
const fetch = vi.fn<FetchLike>();
await expect(client(fetch).request("GET", "/browsers", { signal: AbortSignal.abort() }))
.rejects.toMatchObject({ name: "AbortError" });
expect(fetch).not.toHaveBeenCalled();
});
});
40 changes: 29 additions & 11 deletions browser-use-python/src/browser_use_sdk/_core/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import time
import asyncio
import random
from datetime import datetime
from enum import Enum
from typing import Any
Expand All @@ -12,9 +13,10 @@

from .errors import BrowserUseError

_RETRY_STATUSES = {429}
_RETRY_GET_STATUSES = {502, 503, 504}
_DEFAULT_MAX_RETRIES = 3
_BACKOFF_BASE = 0.5
_MAX_RETRY_DELAY = 10.0


def _clean_json(data: Any) -> Any:
Expand All @@ -34,8 +36,22 @@ def _clean_json(data: Any) -> Any:
return data


def _should_retry(status_code: int) -> bool:
return status_code in _RETRY_STATUSES
def _should_retry(method: str, status_code: int) -> bool:
return status_code == 429 or (
method.upper() == "GET" and status_code in _RETRY_GET_STATUSES
)


def _retry_delay(response: httpx.Response, attempt: int) -> float | None:
"""Honor Cloud's integer-second Retry-After; other formats use normal backoff."""
raw = response.headers.get("Retry-After", "").strip()
retry_after = float(raw) if raw.isascii() and raw.isdigit() else None
# Timeout remains per attempt; preserve the existing ten-second delay cap.
if retry_after is not None and retry_after > _MAX_RETRY_DELAY:
return None
backoff = min(_BACKOFF_BASE * (2 ** min(attempt + 1, 5)), _MAX_RETRY_DELAY)
# Positive jitter spreads clients without violating the server's minimum.
return min(_MAX_RETRY_DELAY, max(backoff, retry_after or 0.0) + random.random() * 0.25)


def _raise_for_status(response: httpx.Response) -> None:
Expand Down Expand Up @@ -89,14 +105,15 @@ def request(
json = _clean_json(json) if json is not None else None
cleaned_params = _clean_params(params)
for attempt in range(self._max_retries + 1):
if attempt > 0:
time.sleep(min(_BACKOFF_BASE * (2 ** attempt), 10))
response = self._client.request(
method, path, json=json, params=cleaned_params, headers=headers
)

if _should_retry(response.status_code) and attempt < self._max_retries:
continue
if _should_retry(method, response.status_code) and attempt < self._max_retries:
delay = _retry_delay(response, attempt)
if delay is not None:
time.sleep(delay)
continue

_raise_for_status(response)
if response.status_code == 204:
Expand Down Expand Up @@ -153,14 +170,15 @@ async def request(
json = _clean_json(json) if json is not None else None
cleaned_params = _clean_params(params)
for attempt in range(self._max_retries + 1):
if attempt > 0:
await asyncio.sleep(min(_BACKOFF_BASE * (2 ** attempt), 10))
response = await self._client.request(
method, path, json=json, params=cleaned_params, headers=headers
)

if _should_retry(response.status_code) and attempt < self._max_retries:
continue
if _should_retry(method, response.status_code) and attempt < self._max_retries:
delay = _retry_delay(response, attempt)
if delay is not None:
await asyncio.sleep(delay)
continue

_raise_for_status(response)
if response.status_code == 204:
Expand Down
Loading