diff --git a/src/kernel/_response.py b/src/kernel/_response.py index a6c5fa30..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,6 +185,13 @@ 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 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/resources/browser_pools.py b/src/kernel/resources/browser_pools.py index 5fc7147a..645c0d27 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 @@ -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 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 new file mode 100644 index 00000000..269838a6 --- /dev/null +++ b/tests/test_browser_pool_acquire_response.py @@ -0,0 +1,70 @@ +import httpx +import pytest + +from kernel import Kernel, AsyncKernel, NotFoundError, APIResponseValidationError + + +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 response.parse(to=str) == "" + assert response.parse(to=bytes) == b"" + assert response.parse(to=httpx.Response) is response.http_response + 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") + + +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")