Skip to content

Commit d424b92

Browse files
rgarciastainless-app[bot]
authored andcommitted
fix: sniff browser pool route cache updates
Keep the browser route cache in sync for pool acquire and release flows so leased sessions can use direct VM routing without resource-specific cache handling. Made-with: Cursor
1 parent 8c519a4 commit d424b92

3 files changed

Lines changed: 120 additions & 11 deletions

File tree

src/kernel/_client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
strip_direct_vm_auth,
3838
rewrite_direct_vm_options,
3939
browser_routing_config_from_env,
40-
maybe_evict_deleted_browser_route_from_response,
40+
maybe_evict_browser_route_from_response,
4141
maybe_populate_browser_route_cache_from_response,
4242
)
4343

@@ -303,7 +303,7 @@ def _process_response(
303303
retries_taken: int = 0,
304304
) -> ResponseT:
305305
maybe_populate_browser_route_cache_from_response(response, cache=self.browser_route_cache)
306-
maybe_evict_deleted_browser_route_from_response(response, cache=self.browser_route_cache)
306+
maybe_evict_browser_route_from_response(response, cache=self.browser_route_cache)
307307
return super()._process_response(
308308
cast_to=cast_to,
309309
options=options,
@@ -618,7 +618,7 @@ async def _process_response(
618618
retries_taken: int = 0,
619619
) -> ResponseT:
620620
maybe_populate_browser_route_cache_from_response(response, cache=self.browser_route_cache)
621-
maybe_evict_deleted_browser_route_from_response(response, cache=self.browser_route_cache)
621+
maybe_evict_browser_route_from_response(response, cache=self.browser_route_cache)
622622
return await super()._process_response(
623623
cast_to=cast_to,
624624
options=options,

src/kernel/lib/browser_routing/routing.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
import re
5+
import json
56
from typing import Any, Mapping, cast
67
from dataclasses import field, dataclass
78
from urllib.parse import unquote
@@ -33,6 +34,8 @@ class BrowserRoutingConfig:
3334

3435
_BROWSER_ROUTE_CACHEABLE_PATH = re.compile(r"^/(?:v\d+/)?browsers(?:/[^/]+)?/?$")
3536
_BROWSER_DELETE_BY_ID_PATH = re.compile(r"^/(?:v\d+/)?browsers/([^/]+)/?$")
37+
_BROWSER_POOL_ACQUIRE_PATH = re.compile(r"^/(?:v\d+/)?browser_pools/[^/]+/acquire/?$")
38+
_BROWSER_POOL_RELEASE_PATH = re.compile(r"^/(?:v\d+/)?browser_pools/[^/]+/release/?$")
3639

3740

3841
def browser_routing_config_from_env() -> BrowserRoutingConfig:
@@ -103,15 +106,11 @@ def maybe_populate_browser_route_cache_from_response(response: httpx.Response, *
103106
return
104107

105108

106-
def maybe_evict_deleted_browser_route_from_response(response: httpx.Response, *, cache: BrowserRouteCache) -> None:
107-
if not response.is_success or response.request.method.upper() != "DELETE":
109+
def maybe_evict_browser_route_from_response(response: httpx.Response, *, cache: BrowserRouteCache) -> None:
110+
if not response.is_success:
108111
return
109112

110-
match = _BROWSER_DELETE_BY_ID_PATH.match(response.request.url.path)
111-
if match is None:
112-
return
113-
114-
session_id = unquote(match.group(1)).strip()
113+
session_id = _session_id_to_evict_from_response(response)
115114
if not session_id:
116115
return
117116

@@ -142,7 +141,51 @@ def _should_populate_browser_route_cache(response: httpx.Response) -> bool:
142141
if "application/json" not in content_type:
143142
return False
144143

145-
return bool(_BROWSER_ROUTE_CACHEABLE_PATH.match(response.request.url.path))
144+
path = response.request.url.path
145+
return bool(_BROWSER_ROUTE_CACHEABLE_PATH.match(path) or _BROWSER_POOL_ACQUIRE_PATH.match(path))
146+
147+
148+
def _session_id_to_evict_from_response(response: httpx.Response) -> str | None:
149+
method = response.request.method.upper()
150+
path = response.request.url.path
151+
152+
if method == "DELETE":
153+
return _session_id_from_browser_delete_path(path)
154+
155+
if method == "POST":
156+
return _session_id_from_browser_pool_release_request(response.request, path)
157+
158+
return None
159+
160+
161+
def _session_id_from_browser_delete_path(path: str) -> str | None:
162+
match = _BROWSER_DELETE_BY_ID_PATH.match(path)
163+
if match is None:
164+
return None
165+
166+
session_id = unquote(match.group(1)).strip()
167+
return session_id or None
168+
169+
170+
def _session_id_from_browser_pool_release_request(request: httpx.Request, path: str) -> str | None:
171+
if _BROWSER_POOL_RELEASE_PATH.match(path) is None:
172+
return None
173+
174+
content_type = request.headers.get("content-type", "").lower()
175+
if "application/json" not in content_type:
176+
return None
177+
178+
try:
179+
body = json.loads(request.content.decode("utf-8"))
180+
except Exception:
181+
return None
182+
183+
session_id = body.get("session_id")
184+
if not isinstance(session_id, str):
185+
return None
186+
187+
normalized = session_id.strip()
188+
return normalized or None
146189

147190

148191
def rewrite_direct_vm_options(

tests/test_browser_routing.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,24 @@ def test_only_browser_metadata_endpoints_warm_route_cache() -> None:
190190
assert response.is_closed is True
191191

192192

193+
@respx.mock
194+
def test_browser_pool_acquire_warms_route_cache() -> None:
195+
acquire_route = respx.post(f"{base_url}/browser_pools/pool-1/acquire").mock(
196+
return_value=httpx.Response(200, json=_fake_browser())
197+
)
198+
routed_request = respx.get("http://browser-session.test/browser/kernel/curl/raw").mock(
199+
return_value=httpx.Response(200, content=b"ok")
200+
)
201+
with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client:
202+
response = client.browser_pools.with_raw_response.acquire("pool-1")
203+
routed = client.browsers.request("sess-1", "GET", "https://example.com")
204+
205+
assert acquire_route.called
206+
assert response.is_closed is True
207+
assert routed.status_code == 200
208+
assert routed_request.called
209+
210+
193211
@respx.mock
194212
def test_browser_delete_by_id_evicts_route_cache() -> None:
195213
delete_route = respx.delete(f"{base_url}/browsers/sess-1").mock(return_value=httpx.Response(204))
@@ -203,6 +221,19 @@ def test_browser_delete_by_id_evicts_route_cache() -> None:
203221
assert response.is_closed is True
204222

205223

224+
@respx.mock
225+
def test_browser_pool_release_evicts_route_cache() -> None:
226+
release_route = respx.post(f"{base_url}/browser_pools/pool-1/release").mock(return_value=httpx.Response(204))
227+
with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client:
228+
_cache_browser(client)
229+
response = client.browser_pools.with_raw_response.release("pool-1", session_id="sess-1")
230+
with pytest.raises(ValueError, match="route cache"):
231+
client.browsers.request("sess-1", "GET", "https://example.com")
232+
233+
assert release_route.called
234+
assert response.is_closed is True
235+
236+
206237
@respx.mock
207238
def test_failed_browser_delete_by_id_keeps_route_cache() -> None:
208239
delete_route = respx.delete(f"{base_url}/browsers/sess-1").mock(
@@ -222,6 +253,41 @@ def test_failed_browser_delete_by_id_keeps_route_cache() -> None:
222253
assert routed_request.called
223254

224255

256+
@respx.mock
257+
def test_failed_browser_pool_release_keeps_route_cache() -> None:
258+
release_route = respx.post(f"{base_url}/browser_pools/pool-1/release").mock(
259+
return_value=httpx.Response(500, json={"error": "boom"})
260+
)
261+
routed_request = respx.get("http://browser-session.test/browser/kernel/curl/raw").mock(
262+
return_value=httpx.Response(200, content=b"ok")
263+
)
264+
with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client:
265+
_cache_browser(client)
266+
with pytest.raises(InternalServerError):
267+
client.browser_pools.release("pool-1", session_id="sess-1")
268+
routed = client.browsers.request("sess-1", "GET", "https://example.com")
269+
270+
assert release_route.called
271+
assert routed.status_code == 200
272+
assert routed_request.called
273+
274+
275+
@pytest.mark.asyncio
276+
@respx.mock
277+
async def test_async_browser_pool_release_evicts_route_cache() -> None:
278+
release_route = respx.post(f"{base_url}/browser_pools/pool-1/release").mock(return_value=httpx.Response(204))
279+
async with AsyncKernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client:
280+
route = browser_route_from_browser(_fake_browser())
281+
assert route is not None
282+
client.browser_route_cache.set(route)
283+
response = await client.browser_pools.with_raw_response.release("pool-1", session_id="sess-1")
284+
with pytest.raises(ValueError, match="route cache"):
285+
await client.browsers.request("sess-1", "GET", "https://example.com")
286+
287+
assert release_route.called
288+
assert response.is_closed is True
289+
290+
225291
def test_browser_route_cache_normalizes_session_id_keys() -> None:
226292
cache = BrowserRouteCache()
227293
cache.set(

0 commit comments

Comments
 (0)