From 04f57b6d1871b86f4f3e6c143014c4930c8e6f2e Mon Sep 17 00:00:00 2001 From: Jayko001 <86390011+Jayko001@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:18:50 +0000 Subject: [PATCH 1/4] feat(browser_pools): typed acquire helper distinguishing got / timed-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `kernel.lib.browser_pools.acquire` (and `acquire_async`) returning a typed discriminated union — `Acquired(browser)` on 200 or `TimedOut()` on 204 — so callers can disambiguate the long-poll timeout from a successful lease without inspecting the raw HTTP status. 404 continues to raise the existing `NotFoundError`. Co-Authored-By: Claude Opus 4.7 --- src/kernel/lib/browser_pools/__init__.py | 17 +++++ src/kernel/lib/browser_pools/acquire.py | 85 +++++++++++++++++++++++ tests/test_browser_pools_typed_acquire.py | 67 ++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 src/kernel/lib/browser_pools/__init__.py create mode 100644 src/kernel/lib/browser_pools/acquire.py create mode 100644 tests/test_browser_pools_typed_acquire.py diff --git a/src/kernel/lib/browser_pools/__init__.py b/src/kernel/lib/browser_pools/__init__.py new file mode 100644 index 0000000..055acdc --- /dev/null +++ b/src/kernel/lib/browser_pools/__init__.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from .acquire import ( + Acquired, + TimedOut, + AcquireResult, + acquire, + acquire_async, +) + +__all__ = [ + "Acquired", + "TimedOut", + "AcquireResult", + "acquire", + "acquire_async", +] diff --git a/src/kernel/lib/browser_pools/acquire.py b/src/kernel/lib/browser_pools/acquire.py new file mode 100644 index 0000000..0e09faa --- /dev/null +++ b/src/kernel/lib/browser_pools/acquire.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Union +from dataclasses import dataclass + +import httpx +from typing_extensions import TypeAlias + +from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from ...types.browser_pool_acquire_response import BrowserPoolAcquireResponse + +if TYPE_CHECKING: + from ..._client import Kernel, AsyncKernel + + +@dataclass +class Acquired: + """A browser was leased from the pool.""" + + browser: BrowserPoolAcquireResponse + + +@dataclass +class TimedOut: + """The long poll expired before a browser became available. Retry to keep waiting.""" + + +AcquireResult: TypeAlias = Union[Acquired, TimedOut] + + +def acquire( + client: "Kernel", + id_or_name: str, + *, + acquire_timeout_seconds: Union[int, Omit] = omit, + extra_headers: Union[Headers, None] = None, + extra_query: Union[Query, None] = None, + extra_body: Union[Body, None] = None, + timeout: Union[float, httpx.Timeout, None, NotGiven] = not_given, +) -> AcquireResult: + """Long-polling acquire that surfaces the HTTP outcome as a typed result. + + Returns one of: + + * :class:`Acquired` — a browser was leased from the pool. + * :class:`TimedOut` — the long poll expired without a browser becoming available. + Retry to keep waiting. + + Raises :class:`kernel.NotFoundError` if the pool does not exist. + """ + raw = client.browser_pools.with_raw_response.acquire( + id_or_name, + acquire_timeout_seconds=acquire_timeout_seconds, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + if raw.http_response.status_code == 204: + return TimedOut() + return Acquired(browser=raw.parse()) + + +async def acquire_async( + client: "AsyncKernel", + id_or_name: str, + *, + acquire_timeout_seconds: Union[int, Omit] = omit, + extra_headers: Union[Headers, None] = None, + extra_query: Union[Query, None] = None, + extra_body: Union[Body, None] = None, + timeout: Union[float, httpx.Timeout, None, NotGiven] = not_given, +) -> AcquireResult: + """Async variant of :func:`acquire`.""" + raw = await client.browser_pools.with_raw_response.acquire( + id_or_name, + acquire_timeout_seconds=acquire_timeout_seconds, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + if raw.http_response.status_code == 204: + return TimedOut() + return Acquired(browser=raw.parse()) diff --git a/tests/test_browser_pools_typed_acquire.py b/tests/test_browser_pools_typed_acquire.py new file mode 100644 index 0000000..83de05f --- /dev/null +++ b/tests/test_browser_pools_typed_acquire.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import os + +import httpx +import respx +import pytest + +from kernel import Kernel, AsyncKernel, NotFoundError +from kernel.lib.browser_pools import Acquired, TimedOut, acquire, acquire_async + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") +api_key = "sk-123" + + +def _browser_payload() -> dict[str, object]: + return { + "session_id": "sess-1", + "base_url": "http://browser-session.test/browser/kernel", + "cdp_ws_url": "wss://browser-session.test/browser/cdp?jwt=t", + "webdriver_ws_url": "wss://x", + "created_at": "2020-01-01T00:00:00Z", + "headless": True, + "stealth": False, + "timeout_seconds": 60, + } + + +@respx.mock +def test_acquire_returns_acquired_on_200() -> None: + respx.post(f"{base_url}/browser_pools/my-pool/acquire").mock( + return_value=httpx.Response(200, json=_browser_payload()) + ) + with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: + result = acquire(client, "my-pool") + + assert isinstance(result, Acquired) + assert result.browser.session_id == "sess-1" + + +@respx.mock +def test_acquire_returns_timed_out_on_204() -> None: + respx.post(f"{base_url}/browser_pools/my-pool/acquire").mock(return_value=httpx.Response(204)) + with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: + result = acquire(client, "my-pool") + + assert isinstance(result, TimedOut) + + +@respx.mock +def test_acquire_raises_not_found_on_404() -> None: + respx.post(f"{base_url}/browser_pools/missing/acquire").mock( + return_value=httpx.Response(404, json={"code": "not_found", "message": "pool not found"}) + ) + with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: + with pytest.raises(NotFoundError): + acquire(client, "missing") + + +@pytest.mark.asyncio +@respx.mock +async def test_acquire_async_returns_timed_out_on_204() -> None: + respx.post(f"{base_url}/browser_pools/my-pool/acquire").mock(return_value=httpx.Response(204)) + async with AsyncKernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: + result = await acquire_async(client, "my-pool") + + assert isinstance(result, TimedOut) From 43e0213719aad83e7c6e197fe8776e80b6b83cfe Mon Sep 17 00:00:00 2001 From: Jayko001 <86390011+Jayko001@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:01:20 +0000 Subject: [PATCH 2/4] add PoolNotFound variant so callers don't need a try/except MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PoolNotFound to AcquireResult and catches NotFoundError internally so all three documented outcomes — Acquired, TimedOut, PoolNotFound — are reachable via the same isinstance() switch. Co-Authored-By: Claude Opus 4.7 --- src/kernel/lib/browser_pools/__init__.py | 2 + src/kernel/lib/browser_pools/acquire.py | 49 ++++++++++++++--------- tests/test_browser_pools_typed_acquire.py | 11 ++--- 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/src/kernel/lib/browser_pools/__init__.py b/src/kernel/lib/browser_pools/__init__.py index 055acdc..8bbaaf1 100644 --- a/src/kernel/lib/browser_pools/__init__.py +++ b/src/kernel/lib/browser_pools/__init__.py @@ -3,6 +3,7 @@ from .acquire import ( Acquired, TimedOut, + PoolNotFound, AcquireResult, acquire, acquire_async, @@ -11,6 +12,7 @@ __all__ = [ "Acquired", "TimedOut", + "PoolNotFound", "AcquireResult", "acquire", "acquire_async", diff --git a/src/kernel/lib/browser_pools/acquire.py b/src/kernel/lib/browser_pools/acquire.py index 0e09faa..f6add5c 100644 --- a/src/kernel/lib/browser_pools/acquire.py +++ b/src/kernel/lib/browser_pools/acquire.py @@ -7,6 +7,7 @@ from typing_extensions import TypeAlias from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from ..._exceptions import NotFoundError from ...types.browser_pool_acquire_response import BrowserPoolAcquireResponse if TYPE_CHECKING: @@ -25,7 +26,12 @@ class TimedOut: """The long poll expired before a browser became available. Retry to keep waiting.""" -AcquireResult: TypeAlias = Union[Acquired, TimedOut] +@dataclass +class PoolNotFound: + """No pool exists with the given id or name.""" + + +AcquireResult: TypeAlias = Union[Acquired, TimedOut, PoolNotFound] def acquire( @@ -45,17 +51,21 @@ def acquire( * :class:`Acquired` — a browser was leased from the pool. * :class:`TimedOut` — the long poll expired without a browser becoming available. Retry to keep waiting. + * :class:`PoolNotFound` — no pool exists with the given id or name. - Raises :class:`kernel.NotFoundError` if the pool does not exist. + Other API errors (auth, server errors, etc.) still raise. """ - raw = client.browser_pools.with_raw_response.acquire( - id_or_name, - acquire_timeout_seconds=acquire_timeout_seconds, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) + try: + raw = client.browser_pools.with_raw_response.acquire( + id_or_name, + acquire_timeout_seconds=acquire_timeout_seconds, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + except NotFoundError: + return PoolNotFound() if raw.http_response.status_code == 204: return TimedOut() return Acquired(browser=raw.parse()) @@ -72,14 +82,17 @@ async def acquire_async( timeout: Union[float, httpx.Timeout, None, NotGiven] = not_given, ) -> AcquireResult: """Async variant of :func:`acquire`.""" - raw = await client.browser_pools.with_raw_response.acquire( - id_or_name, - acquire_timeout_seconds=acquire_timeout_seconds, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) + try: + raw = await client.browser_pools.with_raw_response.acquire( + id_or_name, + acquire_timeout_seconds=acquire_timeout_seconds, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + except NotFoundError: + return PoolNotFound() if raw.http_response.status_code == 204: return TimedOut() return Acquired(browser=raw.parse()) diff --git a/tests/test_browser_pools_typed_acquire.py b/tests/test_browser_pools_typed_acquire.py index 83de05f..094ad3b 100644 --- a/tests/test_browser_pools_typed_acquire.py +++ b/tests/test_browser_pools_typed_acquire.py @@ -6,8 +6,8 @@ import respx import pytest -from kernel import Kernel, AsyncKernel, NotFoundError -from kernel.lib.browser_pools import Acquired, TimedOut, acquire, acquire_async +from kernel import Kernel, AsyncKernel +from kernel.lib.browser_pools import Acquired, TimedOut, PoolNotFound, acquire, acquire_async base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") api_key = "sk-123" @@ -48,13 +48,14 @@ def test_acquire_returns_timed_out_on_204() -> None: @respx.mock -def test_acquire_raises_not_found_on_404() -> None: +def test_acquire_returns_pool_not_found_on_404() -> None: respx.post(f"{base_url}/browser_pools/missing/acquire").mock( return_value=httpx.Response(404, json={"code": "not_found", "message": "pool not found"}) ) with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: - with pytest.raises(NotFoundError): - acquire(client, "missing") + result = acquire(client, "missing") + + assert isinstance(result, PoolNotFound) @pytest.mark.asyncio From 18829ec0bc927592ef71902f33d6c4979371bc38 Mon Sep 17 00:00:00 2001 From: Jayko001 <86390011+Jayko001@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:03:33 +0000 Subject: [PATCH 3/4] sort imports to satisfy ruff Co-Authored-By: Claude Opus 4.7 --- src/kernel/lib/browser_pools/acquire.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kernel/lib/browser_pools/acquire.py b/src/kernel/lib/browser_pools/acquire.py index f6add5c..ff1e6be 100644 --- a/src/kernel/lib/browser_pools/acquire.py +++ b/src/kernel/lib/browser_pools/acquire.py @@ -2,9 +2,9 @@ from typing import TYPE_CHECKING, Union from dataclasses import dataclass +from typing_extensions import TypeAlias import httpx -from typing_extensions import TypeAlias from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from ..._exceptions import NotFoundError From 03a2bacb506bfdd078bc467b6f22b980ad004d3e Mon Sep 17 00:00:00 2001 From: Jayko001 <86390011+Jayko001@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:23:04 +0000 Subject: [PATCH 4/4] await raw.parse() in async variant AsyncAPIResponse.parse() is itself async; without await we constructed Acquired with a coroutine instead of the parsed model. Co-Authored-By: Claude Opus 4.7 --- src/kernel/lib/browser_pools/acquire.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kernel/lib/browser_pools/acquire.py b/src/kernel/lib/browser_pools/acquire.py index ff1e6be..a596a47 100644 --- a/src/kernel/lib/browser_pools/acquire.py +++ b/src/kernel/lib/browser_pools/acquire.py @@ -95,4 +95,4 @@ async def acquire_async( return PoolNotFound() if raw.http_response.status_code == 204: return TimedOut() - return Acquired(browser=raw.parse()) + return Acquired(browser=await raw.parse())