diff --git a/CLAUDE.md b/CLAUDE.md index 51b3f4bdd..cc727cfa3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/browser-use-node/src/core/http.ts b/browser-use-node/src/core/http.ts index b3e4dffe8..e4cbcb98c 100644 --- a/browser-use-node/src/core/http.ts +++ b/browser-use-node/src/core/http.ts @@ -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 { + signal?.throwIfAborted(); + await new Promise((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; @@ -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 @@ -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; } diff --git a/browser-use-node/tests/http.test.ts b/browser-use-node/tests/http.test.ts index 7977cc50d..0a1213f05 100644 --- a/browser-use-node/tests/http.test.ts +++ b/browser-use-node/tests/http.test.ts @@ -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 () => { @@ -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() + .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() + .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().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().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() + .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().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() + .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().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().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().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().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().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(); + await expect(client(fetch).request("GET", "/browsers", { signal: AbortSignal.abort() })) + .rejects.toMatchObject({ name: "AbortError" }); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/browser-use-python/src/browser_use_sdk/_core/http.py b/browser-use-python/src/browser_use_sdk/_core/http.py index 64cfaff46..bd18ce27c 100644 --- a/browser-use-python/src/browser_use_sdk/_core/http.py +++ b/browser-use-python/src/browser_use_sdk/_core/http.py @@ -2,6 +2,7 @@ import time import asyncio +import random from datetime import datetime from enum import Enum from typing import Any @@ -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: @@ -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: @@ -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: @@ -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: diff --git a/browser-use-python/tests/test_http_retries.py b/browser-use-python/tests/test_http_retries.py new file mode 100644 index 000000000..b171594e4 --- /dev/null +++ b/browser-use-python/tests/test_http_retries.py @@ -0,0 +1,270 @@ +"""Exercise retries through real HTTP clients with an in-memory transport.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from types import SimpleNamespace +from typing import Any + +import httpx +import pytest + +from browser_use_sdk._core import http +from browser_use_sdk._core.errors import BrowserUseError +from browser_use_sdk.v4.resources.runs import AsyncRuns, Runs + +RUN_ID = "00000000-0000-0000-0000-000000000001" +SESSION_ID = "00000000-0000-0000-0000-000000000002" + + +@contextmanager +def _replay( + monkeypatch: pytest.MonkeyPatch, + is_async: bool, + handler: Callable[[httpx.Request], httpx.Response], + max_retries: int = 3, +) -> Iterator[tuple[Any, list[float]]]: + delays: list[float] = [] + monkeypatch.setattr(http, "random", SimpleNamespace(random=lambda: 0.5)) + monkeypatch.setattr(http, "time", SimpleNamespace(sleep=delays.append)) + if is_async: + async def sleep(delay: float) -> None: + delays.append(delay) + + async_client = http.AsyncHttpClient("https://api.example.com", "test", max_retries=max_retries) + asyncio.run(async_client.close()) + async_client._client = httpx.AsyncClient( + base_url="https://api.example.com", transport=httpx.MockTransport(handler) + ) + monkeypatch.setattr(http, "asyncio", SimpleNamespace(sleep=sleep)) + try: + yield async_client, delays + finally: + asyncio.run(async_client.close()) + else: + sync_client = http.SyncHttpClient("https://api.example.com", "test", max_retries=max_retries) + sync_client.close() + sync_client._client = httpx.Client( + base_url="https://api.example.com", transport=httpx.MockTransport(handler) + ) + try: + yield sync_client, delays + finally: + sync_client.close() + + +def _request(client: Any, is_async: bool, method: str = "GET") -> Any: + result = client.request(method, "/resource") + return asyncio.run(result) if is_async else result + + +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("status", [502, 503, 504]) +def test_transient_get_recovers(monkeypatch: pytest.MonkeyPatch, is_async: bool, status: int) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if len(requests) == 1: + return httpx.Response(status, json={"detail": "temporary"}, headers={"Retry-After": "2"}) + return httpx.Response(200, json={"result": "ready"}) + + with _replay(monkeypatch, is_async, handler) as (client, delays): + assert _request(client, is_async, "get") == {"result": "ready"} + assert delays == [2.125] + assert len(requests) == 2 + + +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("method,status", [("GET", 503), ("POST", 429)]) +def test_persistent_failure_is_bounded_and_preserves_error( + monkeypatch: pytest.MonkeyPatch, is_async: bool, method: str, status: int, +) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(status, json={"detail": "still unavailable", "reason": "test"}) + + with _replay(monkeypatch, is_async, handler) as (client, delays): + with pytest.raises(BrowserUseError, match="still unavailable") as error: + _request(client, is_async, method) + assert error.value.status_code == status + assert error.value.detail == {"detail": "still unavailable", "reason": "test"} + assert delays == [1.125, 2.125, 4.125] + assert len(requests) == 4 + + +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("header,expected", [ + ("5", 5.125), + (" 5 ", 5.125), + ("0", 1.125), + ("10", 10), + ("", 1.125), + ("-1", 1.125), + ("1.5", 1.125), + ("NaN", 1.125), + ("Thu, 01 Jan 1970 00:00:05 GMT", 1.125), +]) +def test_retry_after_and_jitter( + monkeypatch: pytest.MonkeyPatch, is_async: bool, header: str, expected: float, +) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(429 if len(requests) == 1 else 200, json={}, headers={"Retry-After": header}) + + with _replay(monkeypatch, is_async, handler) as (client, delays): + assert _request(client, is_async, "POST") == {} + assert delays == [expected] + assert len(requests) == 2 + + +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("header", ["11", "60", "9" * 400]) +def test_long_retry_after_returns_error_without_early_retry( + monkeypatch: pytest.MonkeyPatch, is_async: bool, header: str, +) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(429, json={"detail": "wait longer"}, headers={"Retry-After": header}) + + with _replay(monkeypatch, is_async, handler) as (client, delays): + with pytest.raises(BrowserUseError, match="wait longer"): + _request(client, is_async, "POST") + assert delays == [] + assert len(requests) == 1 + + +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("header", [None, "0", "1"]) +def test_short_retry_after_does_not_extend_backoff_cap( + monkeypatch: pytest.MonkeyPatch, is_async: bool, header: str | None, +) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(429, json={"detail": "busy"}, headers={} if header is None else {"Retry-After": header}) + + with _replay(monkeypatch, is_async, handler, max_retries=5) as (client, delays): + with pytest.raises(BrowserUseError): + _request(client, is_async) + assert delays == [1.125, 2.125, 4.125, 8.125, 10.0] + + +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("status", [429, 503]) +def test_zero_retries_sends_once(monkeypatch: pytest.MonkeyPatch, is_async: bool, status: int) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(status, json={"detail": "no retries"}) + + with _replay(monkeypatch, is_async, handler, max_retries=0) as (client, delays): + with pytest.raises(BrowserUseError, match="no retries"): + _request(client, is_async) + assert delays == [] + assert len(requests) == 1 + + +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("method,status", [ + ("POST", 500), ("POST", 502), ("POST", 503), ("POST", 504), + ("PATCH", 503), ("DELETE", 503), ("GET", 500), ("GET", 401), ("GET", 404), +]) +def test_other_errors_are_not_retried( + monkeypatch: pytest.MonkeyPatch, is_async: bool, method: str, status: int, +) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(status, json={"detail": "unchanged"}, headers={"Retry-After": "2"}) + + with _replay(monkeypatch, is_async, handler) as (client, delays): + with pytest.raises(BrowserUseError, match="unchanged"): + _request(client, is_async, method) + assert delays == [] + assert len(requests) == 1 + + +@pytest.mark.parametrize("is_async", [False, True]) +def test_ambiguous_post_transport_error_is_not_retried(monkeypatch: pytest.MonkeyPatch, is_async: bool) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + raise httpx.ReadTimeout("response lost", request=request) + + with _replay(monkeypatch, is_async, handler) as (client, delays): + with pytest.raises(httpx.ReadTimeout, match="response lost"): + _request(client, is_async, "POST") + assert delays == [] + assert len(requests) == 1 + + +@pytest.mark.parametrize("is_async", [False, True]) +def test_completion_wait_recovers_from_status_and_result_errors(monkeypatch: pytest.MonkeyPatch, is_async: bool) -> None: + requests: list[httpx.Request] = [] + responses = [ + httpx.Response(503, json={"detail": "pool timeout"}, headers={"Retry-After": "2"}), + httpx.Response(200, json={"status": "completed"}), + httpx.Response(502, json={"detail": "gateway"}), + httpx.Response(200, json={ + "id": RUN_ID, "task": "Find pricing", "title": None, "model": "minimax-m3", + "contextLimit": 200000, "status": "completed", "result": "done", "error": None, + "sessionId": SESSION_ID, "workspaceId": None, "totalInputTokens": 1, + "totalOutputTokens": 1, "totalCostUsd": "0.01", "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + }), + ] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return responses.pop(0) + + with _replay(monkeypatch, is_async, handler) as (client, delays): + result = asyncio.run(AsyncRuns(client).wait_for_completion(RUN_ID)) if is_async else Runs(client).wait_for_completion(RUN_ID) + assert result.status.value == "completed" + assert result.result == "done" + assert delays == [2.125, 1.125] + assert [(request.method, request.url.path) for request in requests] == [ + ("GET", f"/runs/{RUN_ID}/status"), ("GET", f"/runs/{RUN_ID}/status"), + ("GET", f"/runs/{RUN_ID}"), ("GET", f"/runs/{RUN_ID}"), + ] + + +def test_async_cancellation_during_retry_wait_stops_requests(monkeypatch: pytest.MonkeyPatch) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(503, json={"detail": "temporary"}) + + async def exercise() -> None: + client = http.AsyncHttpClient("https://api.example.com", "test") + await client.close() + client._client = httpx.AsyncClient(base_url="https://api.example.com", transport=httpx.MockTransport(handler)) + waiting = asyncio.Event() + + async def sleep(delay: float) -> None: + waiting.set() + await asyncio.Event().wait() + + monkeypatch.setattr(http, "asyncio", SimpleNamespace(sleep=sleep)) + task = asyncio.create_task(client.request("GET", "/resource")) + try: + await waiting.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + finally: + await client.close() + + asyncio.run(exercise()) + assert len(requests) == 1