Skip to content

Commit a873a18

Browse files
committed
fix: evict deleted browser routes
Drop cached browser routes after successful DELETE /browsers/{id} responses so stale direct-to-VM session URLs are not reused. Cover both the success and failure paths with focused browser routing regressions. Made-with: Cursor
1 parent 563de7d commit a873a18

3 files changed

Lines changed: 53 additions & 1 deletion

File tree

src/kernel/_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +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,
4041
maybe_populate_browser_route_cache_from_response,
4142
)
4243

@@ -302,6 +303,7 @@ def _process_response(
302303
retries_taken: int = 0,
303304
) -> ResponseT:
304305
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)
305307
return super()._process_response(
306308
cast_to=cast_to,
307309
options=options,
@@ -616,6 +618,7 @@ async def _process_response(
616618
retries_taken: int = 0,
617619
) -> ResponseT:
618620
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)
619622
return await super()._process_response(
620623
cast_to=cast_to,
621624
options=options,

src/kernel/lib/browser_routing/routing.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import re
55
from typing import Any, Mapping, cast
66
from dataclasses import field, dataclass
7+
from urllib.parse import unquote
78

89
import httpx
910

@@ -31,6 +32,7 @@ class BrowserRoutingConfig:
3132

3233

3334
_BROWSER_ROUTE_CACHEABLE_PATH = re.compile(r"^/(?:v\d+/)?browsers(?:/[^/]+)?/?$")
35+
_BROWSER_DELETE_BY_ID_PATH = re.compile(r"^/(?:v\d+/)?browsers/([^/]+)/?$")
3436

3537

3638
def browser_routing_config_from_env() -> BrowserRoutingConfig:
@@ -101,6 +103,21 @@ def maybe_populate_browser_route_cache_from_response(response: httpx.Response, *
101103
return
102104

103105

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":
108+
return
109+
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()
115+
if not session_id:
116+
return
117+
118+
cache.delete(session_id)
119+
120+
104121
def populate_browser_route_cache_from_value(value: object, *, cache: BrowserRouteCache) -> None:
105122
if isinstance(value, Mapping):
106123
mapping = cast(Mapping[object, object], value)

tests/test_browser_routing.py

Lines changed: 33 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, AsyncKernel
10+
from kernel import Kernel, AsyncKernel, InternalServerError
1111
from kernel.lib.browser_routing.util import jwt_from_cdp_ws_url
1212
from kernel.lib.browser_routing.routing import (
1313
BrowserRoute,
@@ -190,6 +190,38 @@ 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_delete_by_id_evicts_route_cache() -> None:
195+
delete_route = respx.delete(f"{base_url}/browsers/sess-1").mock(return_value=httpx.Response(204))
196+
with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client:
197+
_cache_browser(client)
198+
response = client.browsers.with_raw_response.delete_by_id("sess-1")
199+
with pytest.raises(ValueError, match="route cache"):
200+
client.browsers.request("sess-1", "GET", "https://example.com")
201+
202+
assert delete_route.called
203+
assert response.is_closed is True
204+
205+
206+
@respx.mock
207+
def test_failed_browser_delete_by_id_keeps_route_cache() -> None:
208+
delete_route = respx.delete(f"{base_url}/browsers/sess-1").mock(
209+
return_value=httpx.Response(500, json={"error": "boom"})
210+
)
211+
routed_request = respx.get("http://browser-session.test/browser/kernel/curl/raw").mock(
212+
return_value=httpx.Response(200, content=b"ok")
213+
)
214+
with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client:
215+
_cache_browser(client)
216+
with pytest.raises(InternalServerError):
217+
client.browsers.delete_by_id("sess-1")
218+
routed = client.browsers.request("sess-1", "GET", "https://example.com")
219+
220+
assert delete_route.called
221+
assert routed.status_code == 200
222+
assert routed_request.called
223+
224+
193225
def test_browser_route_cache_normalizes_session_id_keys() -> None:
194226
cache = BrowserRouteCache()
195227
cache.set(

0 commit comments

Comments
 (0)