Skip to content

Commit 733f523

Browse files
committed
Prevent replaying streams after auth read errors
1 parent bb70883 commit 733f523

4 files changed

Lines changed: 126 additions & 2 deletions

File tree

src/kernel/_base_client.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,9 @@ def _calculate_retry_timeout(
772772
timeout = sleep_seconds * jitter
773773
return timeout if timeout >= 0 else 0
774774

775+
def _should_retry_on_connection_error(self, _request: httpx.Request) -> bool:
776+
return True
777+
775778
def _should_retry(self, response: httpx.Response) -> bool:
776779
# Note: this is not a standard header
777780
should_retry_header = response.headers.get("x-should-retry")
@@ -1026,7 +1029,7 @@ def request(
10261029
except Exception as err:
10271030
log.debug("Encountered Exception", exc_info=True)
10281031

1029-
if remaining_retries > 0:
1032+
if remaining_retries > 0 and self._should_retry_on_connection_error(request):
10301033
self._sleep_for_retry(
10311034
retries_taken=retries_taken,
10321035
max_retries=max_retries,
@@ -1610,7 +1613,7 @@ async def request(
16101613
except Exception as err:
16111614
log.debug("Encountered Exception", exc_info=True)
16121615

1613-
if remaining_retries > 0:
1616+
if remaining_retries > 0 and self._should_retry_on_connection_error(request):
16141617
await self._sleep_for_retry(
16151618
retries_taken=retries_taken,
16161619
max_retries=max_retries,

src/kernel/_client.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
should_retry_stale_direct_vm_auth,
4646
install_stale_direct_vm_auth_eviction,
4747
maybe_evict_browser_route_from_response,
48+
should_retry_direct_vm_connection_error,
4849
install_async_stale_direct_vm_auth_eviction,
4950
maybe_populate_browser_route_cache_from_response,
5051
)
@@ -375,6 +376,10 @@ def _prepare_options(self, options: Any) -> Any:
375376
def _prepare_request(self, request: httpx.Request) -> None:
376377
strip_direct_vm_auth(request, cache=self.browser_route_cache)
377378

379+
@override
380+
def _should_retry_on_connection_error(self, request: httpx.Request) -> bool:
381+
return should_retry_direct_vm_connection_error(request)
382+
378383
@override
379384
def _should_retry(self, response: httpx.Response) -> bool:
380385
if is_stale_direct_vm_auth_response(response):
@@ -767,6 +772,10 @@ async def _prepare_options(self, options: Any) -> Any:
767772
async def _prepare_request(self, request: httpx.Request) -> None:
768773
strip_direct_vm_auth(request, cache=self.browser_route_cache)
769774

775+
@override
776+
def _should_retry_on_connection_error(self, request: httpx.Request) -> bool:
777+
return should_retry_direct_vm_connection_error(request)
778+
770779
@override
771780
def _should_retry(self, response: httpx.Response) -> bool:
772781
if is_stale_direct_vm_auth_response(response):

src/kernel/lib/browser_routing/routing.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class BrowserRoutingConfig:
3333

3434

3535
_EVICTION_HOOK_CACHE_ATTR = "_kernel_browser_route_cache"
36+
_STALE_DIRECT_VM_AUTH_REQUEST_EXTENSION = "kernel_stale_direct_vm_auth"
3637

3738

3839
_BROWSER_ROUTE_CACHEABLE_PATH = re.compile(r"^/(?:v\d+/)?browsers(?:/[^/]+)?/?$")
@@ -220,6 +221,7 @@ def install_stale_direct_vm_auth_eviction(client: httpx.Client, *, cache: Browse
220221

221222
def evict(response: httpx.Response) -> None:
222223
if is_stale_direct_vm_auth_response(response):
224+
response.request.extensions[_STALE_DIRECT_VM_AUTH_REQUEST_EXTENSION] = True
223225
maybe_evict_browser_route_from_response(response, cache=cache)
224226

225227
setattr(evict, _EVICTION_HOOK_CACHE_ATTR, cache)
@@ -234,6 +236,7 @@ def install_async_stale_direct_vm_auth_eviction(client: httpx.AsyncClient, *, ca
234236

235237
async def evict(response: httpx.Response) -> None:
236238
if is_stale_direct_vm_auth_response(response):
239+
response.request.extensions[_STALE_DIRECT_VM_AUTH_REQUEST_EXTENSION] = True
237240
maybe_evict_browser_route_from_response(response, cache=cache)
238241

239242
setattr(evict, _EVICTION_HOOK_CACHE_ATTR, cache)
@@ -246,6 +249,19 @@ def _has_eviction_hook(hooks: list[Any], cache: BrowserRouteCache) -> bool:
246249
return any(getattr(hook, _EVICTION_HOOK_CACHE_ATTR, None) is cache for hook in hooks)
247250

248251

252+
def should_retry_direct_vm_connection_error(request: httpx.Request) -> bool:
253+
"""Prevent generic retries from replaying a stream after stale VM auth.
254+
255+
httpx may raise while eagerly reading the error response body, before the
256+
SDK can pass the response to `_should_retry`. The response hook records the
257+
known stale-auth status on the request so this connection-error path can
258+
apply the same body replay check.
259+
"""
260+
if not request.extensions.get(_STALE_DIRECT_VM_AUTH_REQUEST_EXTENSION):
261+
return True
262+
return direct_vm_request_body_is_replayable(request)
263+
264+
249265
def should_retry_stale_direct_vm_auth(response: httpx.Response) -> bool:
250266
"""Whether a stale direct-to-VM auth failure can be retried on the control plane.
251267

tests/test_browser_routing.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ def _skip_retry_sleep(_self: object, **_kwargs: object) -> None:
5050
return None
5151

5252

53+
async def _skip_async_retry_sleep(_self: object, **_kwargs: object) -> None:
54+
return None
55+
56+
5357
class _UnseekableFile(io.RawIOBase):
5458
"""A file-like upload body that cannot be rewound, e.g. a pipe.
5559
@@ -1347,6 +1351,98 @@ async def handle_request(request: httpx.Request) -> httpx.Response:
13471351
assert requests[1].headers.get("Authorization") == f"Bearer {api_key}"
13481352

13491353

1354+
def test_stale_direct_vm_auth_body_read_failure_does_not_retry_unreplayable_write(
1355+
monkeypatch: pytest.MonkeyPatch,
1356+
) -> None:
1357+
monkeypatch.delenv("KERNEL_BROWSER_ROUTING_SUBRESOURCES", raising=False)
1358+
monkeypatch.setattr("kernel._base_client.SyncAPIClient._sleep_for_retry", _skip_retry_sleep)
1359+
requests: list[tuple[httpx.URL, bytes]] = []
1360+
1361+
def handle_request(request: httpx.Request) -> httpx.Response:
1362+
body = b"".join(request.stream)
1363+
requests.append((request.url, body))
1364+
if "browser-session.test" in str(request.url):
1365+
return httpx.Response(401, stream=_FailingSyncStream(), headers={"content-type": "text/plain"})
1366+
return httpx.Response(201)
1367+
1368+
class Transport(httpx.BaseTransport):
1369+
@override
1370+
def handle_request(self, request: httpx.Request) -> httpx.Response:
1371+
return handle_request(request)
1372+
1373+
http_client = httpx.Client(transport=Transport())
1374+
with Kernel(
1375+
base_url=base_url,
1376+
api_key=api_key,
1377+
http_client=http_client,
1378+
_strict_response_validation=True,
1379+
) as client:
1380+
_cache_browser(client)
1381+
with pytest.raises(APIConnectionError):
1382+
client.browsers.fs.write_file("sess-1", _UnseekableFile(b"payload"), path="/tmp/x")
1383+
1384+
assert requests == [
1385+
(
1386+
httpx.URL("http://browser-session.test/browser/kernel/fs/write_file?path=%2Ftmp%2Fx&jwt=token-abc"),
1387+
b"payload",
1388+
)
1389+
]
1390+
assert client.browser_route_cache.get("sess-1") is None
1391+
1392+
client.browsers.fs.write_file("sess-1", b"next", path="/tmp/x")
1393+
1394+
assert requests[1] == (httpx.URL(f"{base_url}/browsers/sess-1/fs/write_file?path=%2Ftmp%2Fx"), b"next")
1395+
1396+
1397+
@pytest.mark.asyncio
1398+
async def test_async_stale_direct_vm_auth_body_read_failure_does_not_retry_unreplayable_write(
1399+
monkeypatch: pytest.MonkeyPatch,
1400+
) -> None:
1401+
monkeypatch.delenv("KERNEL_BROWSER_ROUTING_SUBRESOURCES", raising=False)
1402+
monkeypatch.setattr("kernel._base_client.AsyncAPIClient._sleep_for_retry", _skip_async_retry_sleep)
1403+
requests: list[tuple[httpx.URL, bytes]] = []
1404+
1405+
async def handle_request(request: httpx.Request) -> httpx.Response:
1406+
body = b"".join([chunk async for chunk in request.stream])
1407+
requests.append((request.url, body))
1408+
if "browser-session.test" in str(request.url):
1409+
return httpx.Response(403, stream=_FailingAsyncStream(), headers={"content-type": "text/plain"})
1410+
return httpx.Response(201)
1411+
1412+
async def payload() -> AsyncIterator[bytes]:
1413+
yield b"payload"
1414+
1415+
class Transport(httpx.AsyncBaseTransport):
1416+
@override
1417+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
1418+
return await handle_request(request)
1419+
1420+
http_client = httpx.AsyncClient(transport=Transport())
1421+
async with AsyncKernel(
1422+
base_url=base_url,
1423+
api_key=api_key,
1424+
http_client=http_client,
1425+
_strict_response_validation=True,
1426+
) as client:
1427+
route = browser_route_from_browser(_fake_browser())
1428+
assert route is not None
1429+
client.browser_route_cache.set(route)
1430+
with pytest.raises(APIConnectionError):
1431+
await client.browsers.fs.write_file("sess-1", cast(Any, payload()), path="/tmp/x")
1432+
1433+
assert requests == [
1434+
(
1435+
httpx.URL("http://browser-session.test/browser/kernel/fs/write_file?path=%2Ftmp%2Fx&jwt=token-abc"),
1436+
b"payload",
1437+
)
1438+
]
1439+
assert client.browser_route_cache.get("sess-1") is None
1440+
1441+
await client.browsers.fs.write_file("sess-1", b"next", path="/tmp/x")
1442+
1443+
assert requests[1] == (httpx.URL(f"{base_url}/browsers/sess-1/fs/write_file?path=%2Ftmp%2Fx"), b"next")
1444+
1445+
13501446
def test_copied_client_registers_one_route_eviction_hook() -> None:
13511447
with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client:
13521448
copied = client.copy(api_key="sk-456")

0 commit comments

Comments
 (0)