Skip to content

Commit d2b0e6d

Browse files
rgarciastainless-app[bot]
authored andcommitted
refactor: sniff browser routes in response hooks
Move browser route cache warming into the shared sync and async response hooks so browser metadata endpoints populate the cache consistently, including raw responses. Remove the handwritten browsers resource priming and cover the narrowed sniffing behavior with focused routing tests. Made-with: Cursor
1 parent 6e3b38a commit d2b0e6d

4 files changed

Lines changed: 156 additions & 31 deletions

File tree

src/kernel/_client.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
import os
6-
from typing import TYPE_CHECKING, Any, Dict, Mapping, cast
6+
from typing import TYPE_CHECKING, Any, Dict, Type, Mapping, cast
77
from typing_extensions import Self, Literal, override
88

99
import httpx
@@ -14,13 +14,15 @@
1414
Omit,
1515
Timeout,
1616
NotGiven,
17+
ResponseT,
1718
Transport,
1819
ProxiesTypes,
1920
RequestOptions,
2021
not_given,
2122
)
2223
from ._utils import is_given, get_async_library
2324
from ._compat import cached_property
25+
from ._models import FinalRequestOptions
2426
from ._version import __version__
2527
from ._streaming import Stream as Stream, AsyncStream as AsyncStream
2628
from ._exceptions import KernelError, APIStatusError
@@ -35,6 +37,7 @@
3537
strip_direct_vm_auth,
3638
rewrite_direct_vm_options,
3739
browser_routing_config_from_env,
40+
maybe_populate_browser_route_cache_from_response,
3841
)
3942

4043
if TYPE_CHECKING:
@@ -287,6 +290,27 @@ def _prepare_options(self, options: Any) -> Any:
287290
def _prepare_request(self, request: httpx.Request) -> None:
288291
strip_direct_vm_auth(request, cache=self.browser_route_cache)
289292

293+
@override
294+
def _process_response(
295+
self,
296+
*,
297+
cast_to: Type[ResponseT],
298+
options: FinalRequestOptions,
299+
response: httpx.Response,
300+
stream: bool,
301+
stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None,
302+
retries_taken: int = 0,
303+
) -> ResponseT:
304+
maybe_populate_browser_route_cache_from_response(response, cache=self.browser_route_cache)
305+
return super()._process_response(
306+
cast_to=cast_to,
307+
options=options,
308+
response=response,
309+
stream=stream,
310+
stream_cls=stream_cls,
311+
retries_taken=retries_taken,
312+
)
313+
290314
def copy(
291315
self,
292316
*,
@@ -580,6 +604,27 @@ async def _prepare_options(self, options: Any) -> Any:
580604
async def _prepare_request(self, request: httpx.Request) -> None:
581605
strip_direct_vm_auth(request, cache=self.browser_route_cache)
582606

607+
@override
608+
async def _process_response(
609+
self,
610+
*,
611+
cast_to: Type[ResponseT],
612+
options: FinalRequestOptions,
613+
response: httpx.Response,
614+
stream: bool,
615+
stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None,
616+
retries_taken: int = 0,
617+
) -> ResponseT:
618+
maybe_populate_browser_route_cache_from_response(response, cache=self.browser_route_cache)
619+
return await super()._process_response(
620+
cast_to=cast_to,
621+
options=options,
622+
response=response,
623+
stream=stream,
624+
stream_cls=stream_cls,
625+
retries_taken=retries_taken,
626+
)
627+
583628
def copy(
584629
self,
585630
*,

src/kernel/lib/browser_routing/routing.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ import annotations
22

33
import os
4-
from typing import Any
4+
import re
5+
from typing import Any, Mapping, cast
56
from dataclasses import field, dataclass
67

78
import httpx
@@ -14,6 +15,7 @@
1415
)
1516
from ..._compat import model_copy
1617
from ..._models import FinalRequestOptions
18+
from ..._constants import RAW_RESPONSE_HEADER
1719

1820

1921
@dataclass
@@ -28,6 +30,9 @@ class BrowserRoutingConfig:
2830
subresources: tuple[str, ...] = field(default_factory=tuple)
2931

3032

33+
_BROWSER_ROUTE_CACHEABLE_PATH = re.compile(r"^/(?:v\d+/)?browsers(?:/[^/]+)?/?$")
34+
35+
3136
def browser_routing_config_from_env() -> BrowserRoutingConfig:
3237
raw = os.environ.get("KERNEL_BROWSER_ROUTING_SUBRESOURCES")
3338
if raw is None:
@@ -85,6 +90,44 @@ def _normalize_session_id(session_id: str) -> str:
8590
return session_id.strip()
8691

8792

93+
def maybe_populate_browser_route_cache_from_response(response: httpx.Response, *, cache: BrowserRouteCache) -> None:
94+
if not _should_populate_browser_route_cache(response):
95+
return
96+
97+
try:
98+
populate_browser_route_cache_from_value(response.json(), cache=cache)
99+
except Exception:
100+
# Ignore malformed JSON in routing cache population.
101+
return
102+
103+
104+
def populate_browser_route_cache_from_value(value: object, *, cache: BrowserRouteCache) -> None:
105+
if isinstance(value, Mapping):
106+
mapping = cast(Mapping[object, object], value)
107+
route = browser_route_from_browser(mapping)
108+
if route is not None:
109+
cache.set(route)
110+
111+
for child in mapping.values():
112+
populate_browser_route_cache_from_value(child, cache=cache)
113+
return
114+
115+
if isinstance(value, list):
116+
for item in cast(list[object], value):
117+
populate_browser_route_cache_from_value(item, cache=cache)
118+
119+
120+
def _should_populate_browser_route_cache(response: httpx.Response) -> bool:
121+
if response.request.headers.get(RAW_RESPONSE_HEADER) == "stream":
122+
return False
123+
124+
content_type = response.headers.get("content-type", "").lower()
125+
if "application/json" not in content_type:
126+
return False
127+
128+
return bool(_BROWSER_ROUTE_CACHEABLE_PATH.match(response.request.url.path))
129+
130+
88131
def rewrite_direct_vm_options(
89132
options: FinalRequestOptions,
90133
*,

src/kernel/resources/browsers/browsers.py

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@
7979
)
8080
from ...pagination import SyncOffsetPagination, AsyncOffsetPagination
8181
from ..._base_client import AsyncPaginator, make_request_options
82-
from ...lib.browser_routing.routing import browser_route_from_browser
8382
from ...types.browser_curl_response import BrowserCurlResponse
8483
from ...types.browser_list_response import BrowserListResponse
8584
from ...lib.browser_routing.raw_http import (
@@ -250,9 +249,6 @@ def create(
250249
),
251250
cast_to=BrowserCreateResponse,
252251
)
253-
route = browser_route_from_browser(result)
254-
if route is not None:
255-
self._client.browser_route_cache.set(route)
256252
return result
257253

258254
def retrieve(
@@ -296,9 +292,6 @@ def retrieve(
296292
),
297293
cast_to=BrowserRetrieveResponse,
298294
)
299-
route = browser_route_from_browser(result)
300-
if route is not None:
301-
self._client.browser_route_cache.set(route)
302295
return result
303296

304297
def update(
@@ -357,9 +350,6 @@ def update(
357350
),
358351
cast_to=BrowserUpdateResponse,
359352
)
360-
route = browser_route_from_browser(result)
361-
if route is not None:
362-
self._client.browser_route_cache.set(route)
363353
return result
364354

365355
def list(
@@ -424,10 +414,6 @@ def list(
424414
),
425415
model=BrowserListResponse,
426416
)
427-
for item in page.items:
428-
route = browser_route_from_browser(item)
429-
if route is not None:
430-
self._client.browser_route_cache.set(route)
431417
return page
432418

433419
@typing_extensions.deprecated("deprecated")
@@ -825,9 +811,6 @@ async def create(
825811
),
826812
cast_to=BrowserCreateResponse,
827813
)
828-
route = browser_route_from_browser(result)
829-
if route is not None:
830-
self._client.browser_route_cache.set(route)
831814
return result
832815

833816
async def retrieve(
@@ -871,9 +854,6 @@ async def retrieve(
871854
),
872855
cast_to=BrowserRetrieveResponse,
873856
)
874-
route = browser_route_from_browser(result)
875-
if route is not None:
876-
self._client.browser_route_cache.set(route)
877857
return result
878858

879859
async def update(
@@ -932,9 +912,6 @@ async def update(
932912
),
933913
cast_to=BrowserUpdateResponse,
934914
)
935-
route = browser_route_from_browser(result)
936-
if route is not None:
937-
self._client.browser_route_cache.set(route)
938915
return result
939916

940917
def list(
@@ -999,11 +976,6 @@ def list(
999976
),
1000977
model=BrowserListResponse,
1001978
)
1002-
typed_page = cast(AsyncOffsetPagination[BrowserListResponse], page)
1003-
for item in typed_page.items:
1004-
route = browser_route_from_browser(item)
1005-
if route is not None:
1006-
self._client.browser_route_cache.set(route)
1007979
return page
1008980

1009981
@typing_extensions.deprecated("deprecated")

tests/test_browser_routing.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import respx
88
import pytest
99

10-
from kernel import Kernel
10+
from kernel import Kernel, AsyncKernel
1111
from kernel.lib.browser_routing.util import jwt_from_cdp_ws_url
1212
from kernel.lib.browser_routing.routing import (
1313
BrowserRoute,
@@ -125,6 +125,71 @@ def test_browser_request_requires_cached_route() -> None:
125125
client.browsers.request("sess-1", "GET", "https://example.com")
126126

127127

128+
@respx.mock
129+
def test_browser_create_warms_route_cache() -> None:
130+
create_route = respx.post(f"{base_url}/browsers").mock(return_value=httpx.Response(200, json=_fake_browser()))
131+
routed_request = respx.get("http://browser-session.test/browser/kernel/curl/raw").mock(
132+
return_value=httpx.Response(200, content=b"ok")
133+
)
134+
with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client:
135+
browser = client.browsers.create()
136+
routed = client.browsers.request(browser.session_id, "GET", "https://example.com")
137+
138+
assert create_route.called
139+
assert browser.session_id == "sess-1"
140+
assert routed.status_code == 200
141+
assert routed_request.called
142+
143+
144+
@respx.mock
145+
def test_raw_browser_create_warms_route_cache() -> None:
146+
create_route = respx.post(f"{base_url}/browsers").mock(return_value=httpx.Response(200, json=_fake_browser()))
147+
routed_request = respx.get("http://browser-session.test/browser/kernel/curl/raw").mock(
148+
return_value=httpx.Response(200, content=b"ok")
149+
)
150+
with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client:
151+
response = client.browsers.with_raw_response.create()
152+
routed = client.browsers.request("sess-1", "GET", "https://example.com")
153+
154+
assert create_route.called
155+
assert response.is_closed is True
156+
assert routed.status_code == 200
157+
assert routed.content == b"ok"
158+
request = cast(httpx.Request, cast(Any, routed_request.calls[0]).request)
159+
assert request.url.params.get("jwt") == "token-abc"
160+
161+
162+
@pytest.mark.asyncio
163+
@respx.mock
164+
async def test_async_raw_browser_create_warms_route_cache() -> None:
165+
create_route = respx.post(f"{base_url}/browsers").mock(return_value=httpx.Response(200, json=_fake_browser()))
166+
routed_request = respx.get("http://browser-session.test/browser/kernel/curl/raw").mock(
167+
return_value=httpx.Response(200, content=b"ok")
168+
)
169+
async with AsyncKernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client:
170+
response = await client.browsers.with_raw_response.create()
171+
routed = await client.browsers.request("sess-1", "GET", "https://example.com")
172+
173+
assert create_route.called
174+
assert response.is_closed is True
175+
assert routed.status_code == 200
176+
assert routed.content == b"ok"
177+
request = cast(httpx.Request, cast(Any, routed_request.calls[0]).request)
178+
assert request.url.params.get("jwt") == "token-abc"
179+
180+
181+
@respx.mock
182+
def test_only_browser_metadata_endpoints_warm_route_cache() -> None:
183+
projects_route = respx.get(f"{base_url}/projects").mock(return_value=httpx.Response(200, json=_fake_browser()))
184+
with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client:
185+
response = client.projects.with_raw_response.list()
186+
with pytest.raises(ValueError, match="route cache"):
187+
client.browsers.request("sess-1", "GET", "https://example.com")
188+
189+
assert projects_route.called
190+
assert response.is_closed is True
191+
192+
128193
def test_browser_route_cache_normalizes_session_id_keys() -> None:
129194
cache = BrowserRouteCache()
130195
cache.set(

0 commit comments

Comments
 (0)