From 3726de36fc56e3c935ffc27110f76d6e36744c1e Mon Sep 17 00:00:00 2001 From: Sayan- <1415138+Sayan-@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:41:46 +0000 Subject: [PATCH 1/4] Return no browser on pool acquire timeout --- src/kernel/_response.py | 3 +++ src/kernel/resources/browser_pools.py | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/kernel/_response.py b/src/kernel/_response.py index a6c5fa30..5bb3c824 100644 --- a/src/kernel/_response.py +++ b/src/kernel/_response.py @@ -16,6 +16,7 @@ Iterator, AsyncIterator, cast, + get_args, overload, ) from typing_extensions import Awaitable, ParamSpec, override, get_origin @@ -185,6 +186,8 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: return cast(R, None) response = self.http_response + if response.status_code == 204 and NoneType in get_args(cast_to): + return cast(R, None) if cast_to == str: return cast(R, response.text) diff --git a/src/kernel/resources/browser_pools.py b/src/kernel/resources/browser_pools.py index 5fc7147a..12be4e38 100644 --- a/src/kernel/resources/browser_pools.py +++ b/src/kernel/resources/browser_pools.py @@ -542,7 +542,7 @@ def acquire( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BrowserPoolAcquireResponse: + ) -> Optional[BrowserPoolAcquireResponse]: """Long-polling endpoint to acquire a browser from the pool. Returns immediately @@ -607,7 +607,7 @@ def acquire( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=BrowserPoolAcquireResponse, + cast_to=Optional[BrowserPoolAcquireResponse], ) def flush( @@ -1199,7 +1199,7 @@ async def acquire( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BrowserPoolAcquireResponse: + ) -> Optional[BrowserPoolAcquireResponse]: """Long-polling endpoint to acquire a browser from the pool. Returns immediately @@ -1264,7 +1264,7 @@ async def acquire( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=BrowserPoolAcquireResponse, + cast_to=Optional[BrowserPoolAcquireResponse], ) async def flush( From 9ea2e02673df5099f3ff37179e81bbc137891bd7 Mon Sep 17 00:00:00 2001 From: Sayan- <1415138+Sayan-@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:41:46 +0000 Subject: [PATCH 2/4] Test pool acquire response outcomes --- tests/test_browser_pool_acquire_response.py | 57 +++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 tests/test_browser_pool_acquire_response.py diff --git a/tests/test_browser_pool_acquire_response.py b/tests/test_browser_pool_acquire_response.py new file mode 100644 index 00000000..90bc5286 --- /dev/null +++ b/tests/test_browser_pool_acquire_response.py @@ -0,0 +1,57 @@ +import httpx +import pytest + +from kernel import Kernel, AsyncKernel, NotFoundError + + +def test_acquire_returns_browser() -> None: + browser = { + "cdp_ws_url": "wss://example.test/cdp", + "created_at": "2026-10-09T00:00:00Z", + "headless": True, + "memory": "1GiB", + "region": "us-east", + "session_id": "session-1", + "stealth": False, + "webdriver_ws_url": "wss://example.test/webdriver", + } + with Kernel( + api_key="test", + http_client=httpx.Client(transport=httpx.MockTransport(lambda _request: httpx.Response(200, json=browser))), + ) as client: + result = client.browser_pools.acquire("pool") + assert result is not None + assert result.session_id == "session-1" + + +def test_acquire_returns_none_on_poll_timeout() -> None: + calls = 0 + + def handle(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(204) + + with Kernel(api_key="test", http_client=httpx.Client(transport=httpx.MockTransport(handle))) as client: + assert client.browser_pools.acquire("pool") is None + response = client.browser_pools.with_raw_response.acquire("pool") + assert response.status_code == 204 + assert response.parse() is None + assert calls == 2 + + +async def test_async_acquire_returns_none_on_poll_timeout() -> None: + async with AsyncKernel( + api_key="test", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(204))), + ) as client: + assert await client.browser_pools.acquire("pool") is None + + +def test_acquire_still_raises_not_found() -> None: + with Kernel( + api_key="test", + http_client=httpx.Client(transport=httpx.MockTransport(lambda _request: httpx.Response(404, json={}))), + ) as client: + with pytest.raises(NotFoundError): + client.browser_pools.acquire("missing") From 22a01758d988db5c331fc30d5804bd7f824b552b Mon Sep 17 00:00:00 2001 From: Sayan- <1415138+Sayan-@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:45:30 +0000 Subject: [PATCH 3/4] Preserve explicit parsing on empty responses --- src/kernel/_response.py | 3 +-- src/kernel/resources/browser_pools.py | 4 ++-- tests/test_browser_pool_acquire_response.py | 3 +++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/kernel/_response.py b/src/kernel/_response.py index 5bb3c824..8ccf2e6a 100644 --- a/src/kernel/_response.py +++ b/src/kernel/_response.py @@ -16,7 +16,6 @@ Iterator, AsyncIterator, cast, - get_args, overload, ) from typing_extensions import Awaitable, ParamSpec, override, get_origin @@ -186,7 +185,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: return cast(R, None) response = self.http_response - if response.status_code == 204 and NoneType in get_args(cast_to): + if response.status_code == 204 and to is None and is_basemodel(cast_to): return cast(R, None) if cast_to == str: return cast(R, response.text) diff --git a/src/kernel/resources/browser_pools.py b/src/kernel/resources/browser_pools.py index 12be4e38..645c0d27 100644 --- a/src/kernel/resources/browser_pools.py +++ b/src/kernel/resources/browser_pools.py @@ -607,7 +607,7 @@ def acquire( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=Optional[BrowserPoolAcquireResponse], + cast_to=BrowserPoolAcquireResponse, ) def flush( @@ -1264,7 +1264,7 @@ async def acquire( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=Optional[BrowserPoolAcquireResponse], + cast_to=BrowserPoolAcquireResponse, ) async def flush( diff --git a/tests/test_browser_pool_acquire_response.py b/tests/test_browser_pool_acquire_response.py index 90bc5286..c05cce70 100644 --- a/tests/test_browser_pool_acquire_response.py +++ b/tests/test_browser_pool_acquire_response.py @@ -37,6 +37,9 @@ def handle(_request: httpx.Request) -> httpx.Response: response = client.browser_pools.with_raw_response.acquire("pool") assert response.status_code == 204 assert response.parse() is None + assert response.parse(to=str) == "" + assert response.parse(to=bytes) == b"" + assert response.parse(to=httpx.Response) is response.http_response assert calls == 2 From 25cdf957c99b675bc1f6c3eaaf174e2031d0a55b Mon Sep 17 00:00:00 2001 From: Sayan- <1415138+Sayan-@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:49:00 +0000 Subject: [PATCH 4/4] Limit empty acquire parsing to its response model --- src/kernel/_response.py | 9 +++++++-- src/kernel/_types.py | 6 ++++++ src/kernel/types/browser_pool_acquire_response.py | 3 ++- tests/test_browser_pool_acquire_response.py | 12 +++++++++++- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/kernel/_response.py b/src/kernel/_response.py index 8ccf2e6a..95d81625 100644 --- a/src/kernel/_response.py +++ b/src/kernel/_response.py @@ -24,7 +24,7 @@ import httpx import pydantic -from ._types import NoneType +from ._types import NoneType, NoContentAsNone from ._utils import is_given, extract_type_arg, is_annotated_type, is_type_alias_type, extract_type_var_from_base from ._models import BaseModel, is_basemodel from ._constants import RAW_RESPONSE_HEADER, OVERRIDE_CAST_TO_HEADER @@ -185,7 +185,12 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: return cast(R, None) response = self.http_response - if response.status_code == 204 and to is None and is_basemodel(cast_to): + if ( + response.status_code == 204 + and to is None + and isinstance(origin, type) + and issubclass(origin, NoContentAsNone) + ): return cast(R, None) if cast_to == str: return cast(R, response.text) diff --git a/src/kernel/_types.py b/src/kernel/_types.py index d924c204..94982715 100644 --- a/src/kernel/_types.py +++ b/src/kernel/_types.py @@ -44,6 +44,12 @@ Query = Mapping[str, object] Body = object AnyMapping = Mapping[str, object] + + +class NoContentAsNone: + pass + + ModelT = TypeVar("ModelT", bound=pydantic.BaseModel) _T = TypeVar("_T") diff --git a/src/kernel/types/browser_pool_acquire_response.py b/src/kernel/types/browser_pool_acquire_response.py index 4cbda2d3..8791bb7b 100644 --- a/src/kernel/types/browser_pool_acquire_response.py +++ b/src/kernel/types/browser_pool_acquire_response.py @@ -5,6 +5,7 @@ from typing_extensions import Literal from .tags import Tags +from .._types import NoContentAsNone from .profile import Profile from .._models import BaseModel from .browser_proxy import BrowserProxy @@ -20,7 +21,7 @@ __all__ = ["BrowserPoolAcquireResponse"] -class BrowserPoolAcquireResponse(BaseModel): +class BrowserPoolAcquireResponse(NoContentAsNone, BaseModel): cdp_ws_url: str """Websocket URL for Chrome DevTools Protocol connections to the browser session""" diff --git a/tests/test_browser_pool_acquire_response.py b/tests/test_browser_pool_acquire_response.py index c05cce70..269838a6 100644 --- a/tests/test_browser_pool_acquire_response.py +++ b/tests/test_browser_pool_acquire_response.py @@ -1,7 +1,7 @@ import httpx import pytest -from kernel import Kernel, AsyncKernel, NotFoundError +from kernel import Kernel, AsyncKernel, NotFoundError, APIResponseValidationError def test_acquire_returns_browser() -> None: @@ -58,3 +58,13 @@ def test_acquire_still_raises_not_found() -> None: ) as client: with pytest.raises(NotFoundError): client.browser_pools.acquire("missing") + + +def test_unexpected_204_still_fails_strict_validation() -> None: + with Kernel( + api_key="test", + _strict_response_validation=True, + http_client=httpx.Client(transport=httpx.MockTransport(lambda _request: httpx.Response(204))), + ) as client: + with pytest.raises(APIResponseValidationError): + client.browser_pools.retrieve("pool")