Skip to content

Commit 5d8ec7f

Browse files
committed
Preserve stream replayability before request hooks
1 parent f33e9ca commit 5d8ec7f

4 files changed

Lines changed: 52 additions & 15 deletions

File tree

src/kernel/_base_client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,7 +1015,7 @@ def request(
10151015
except httpx.TimeoutException as err:
10161016
log.debug("Encountered httpx.TimeoutException", exc_info=True)
10171017

1018-
if remaining_retries > 0:
1018+
if remaining_retries > 0 and self._should_retry_on_connection_error(request):
10191019
self._sleep_for_retry(
10201020
retries_taken=retries_taken,
10211021
max_retries=max_retries,
@@ -1599,7 +1599,7 @@ async def request(
15991599
except httpx.TimeoutException as err:
16001600
log.debug("Encountered httpx.TimeoutException", exc_info=True)
16011601

1602-
if remaining_retries > 0:
1602+
if remaining_retries > 0 and self._should_retry_on_connection_error(request):
16031603
await self._sleep_for_retry(
16041604
retries_taken=retries_taken,
16051605
max_retries=max_retries,

src/kernel/_client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
from .lib.browser_routing.routing import (
3939
BrowserRouteCache,
4040
BrowserRoutingConfig,
41-
strip_direct_vm_auth,
41+
prepare_direct_vm_request,
4242
rewrite_direct_vm_options,
4343
browser_routing_config_from_env,
4444
is_stale_direct_vm_auth_response,
@@ -374,7 +374,7 @@ def _prepare_options(self, options: Any) -> Any:
374374

375375
@override
376376
def _prepare_request(self, request: httpx.Request) -> None:
377-
strip_direct_vm_auth(request, cache=self.browser_route_cache)
377+
prepare_direct_vm_request(request, cache=self.browser_route_cache)
378378

379379
@override
380380
def _should_retry_on_connection_error(self, request: httpx.Request) -> bool:
@@ -770,7 +770,7 @@ async def _prepare_options(self, options: Any) -> Any:
770770

771771
@override
772772
async def _prepare_request(self, request: httpx.Request) -> None:
773-
strip_direct_vm_auth(request, cache=self.browser_route_cache)
773+
prepare_direct_vm_request(request, cache=self.browser_route_cache)
774774

775775
@override
776776
def _should_retry_on_connection_error(self, request: httpx.Request) -> bool:

src/kernel/lib/browser_routing/routing.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class BrowserRoutingConfig:
3434

3535
_EVICTION_HOOK_CACHE_ATTR = "_kernel_browser_route_cache"
3636
_STALE_DIRECT_VM_AUTH_REQUEST_EXTENSION = "kernel_stale_direct_vm_auth"
37+
_DIRECT_VM_BODY_REPLAYABLE_REQUEST_EXTENSION = "kernel_direct_vm_body_replayable"
3738

3839

3940
_BROWSER_ROUTE_CACHEABLE_PATH = re.compile(r"^/(?:v\d+/)?browsers(?:/[^/]+)?/?$")
@@ -276,12 +277,18 @@ def should_retry_stale_direct_vm_auth(response: httpx.Response) -> bool:
276277

277278

278279
def direct_vm_request_body_is_replayable(request: httpx.Request) -> bool:
280+
replayable = request.extensions.get(_DIRECT_VM_BODY_REPLAYABLE_REQUEST_EXTENSION)
281+
if isinstance(replayable, bool):
282+
return replayable
283+
return _classify_direct_vm_request_body_replayability(request)
284+
285+
286+
def _classify_direct_vm_request_body_replayability(request: httpx.Request) -> bool:
279287
try:
280288
_ = request.content
281289
except httpx.RequestNotRead:
282290
pass
283291
else:
284-
# httpx already buffered the body, so rebuilding it yields the same bytes.
285292
return True
286293

287294
# httpx encodes multipart bodies as a stream of fields it re-renders per attempt.
@@ -410,10 +417,15 @@ def rewrite_direct_vm_options(
410417
return rewritten
411418

412419

413-
def strip_direct_vm_auth(request: httpx.Request, *, cache: BrowserRouteCache) -> None:
420+
def prepare_direct_vm_request(request: httpx.Request, *, cache: BrowserRouteCache) -> None:
414421
raw = str(request.url)
415422
for route in cache.values():
416423
if raw.startswith(route.base_url.rstrip("/") + "/"):
424+
# Request hooks and custom auth can buffer this request while consuming
425+
# the original body that the SDK would use to build a retry.
426+
request.extensions[_DIRECT_VM_BODY_REPLAYABLE_REQUEST_EXTENSION] = (
427+
_classify_direct_vm_request_body_replayability(request)
428+
)
417429
request.headers.pop("Authorization", None)
418430
return
419431

tests/test_browser_routing.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from kernel import (
1515
Kernel,
1616
AsyncKernel,
17+
APITimeoutError,
1718
APIConnectionError,
1819
AuthenticationError,
1920
InternalServerError,
@@ -1269,15 +1270,21 @@ def test_indexed_multipart_body_flattens_only_given_values() -> None:
12691270
class _FailingSyncStream(httpx.SyncByteStream):
12701271
"""A response body that fails while it is being read."""
12711272

1273+
def __init__(self, error_type: type[httpx.TransportError] = httpx.ReadError) -> None:
1274+
self.error_type = error_type
1275+
12721276
@override
12731277
def __iter__(self) -> Iterator[bytes]:
1274-
raise httpx.ReadError("connection reset while reading the error body")
1278+
raise self.error_type("connection failed while reading the error body")
12751279

12761280

12771281
class _FailingAsyncStream(httpx.AsyncByteStream):
1282+
def __init__(self, error_type: type[httpx.TransportError] = httpx.ReadError) -> None:
1283+
self.error_type = error_type
1284+
12781285
@override
12791286
async def __aiter__(self) -> AsyncIterator[bytes]:
1280-
raise httpx.ReadError("connection reset while reading the error body")
1287+
raise self.error_type("connection failed while reading the error body")
12811288
yield b"" # pragma: no cover - unreachable, keeps this an async generator
12821289

12831290

@@ -1351,8 +1358,14 @@ async def handle_request(request: httpx.Request) -> httpx.Response:
13511358
assert requests[1].headers.get("Authorization") == f"Bearer {api_key}"
13521359

13531360

1361+
@pytest.mark.parametrize(
1362+
("error_type", "expected_error"),
1363+
[(httpx.ReadError, APIConnectionError), (httpx.ReadTimeout, APITimeoutError)],
1364+
)
13541365
def test_stale_direct_vm_auth_body_read_failure_does_not_retry_unreplayable_write(
13551366
monkeypatch: pytest.MonkeyPatch,
1367+
error_type: type[httpx.TransportError],
1368+
expected_error: type[Exception],
13561369
) -> None:
13571370
monkeypatch.delenv("KERNEL_BROWSER_ROUTING_SUBRESOURCES", raising=False)
13581371
monkeypatch.setattr("kernel._base_client.SyncAPIClient._sleep_for_retry", _skip_retry_sleep)
@@ -1362,23 +1375,26 @@ def handle_request(request: httpx.Request) -> httpx.Response:
13621375
body = b"".join(cast(Iterator[bytes], request.stream))
13631376
requests.append((request.url, body))
13641377
if "browser-session.test" in str(request.url):
1365-
return httpx.Response(401, stream=_FailingSyncStream(), headers={"content-type": "text/plain"})
1378+
return httpx.Response(401, stream=_FailingSyncStream(error_type), headers={"content-type": "text/plain"})
13661379
return httpx.Response(201)
13671380

1381+
def read_request(request: httpx.Request) -> None:
1382+
request.read()
1383+
13681384
class Transport(httpx.BaseTransport):
13691385
@override
13701386
def handle_request(self, request: httpx.Request) -> httpx.Response:
13711387
return handle_request(request)
13721388

1373-
http_client = httpx.Client(transport=Transport())
1389+
http_client = httpx.Client(transport=Transport(), event_hooks={"request": [read_request]})
13741390
with Kernel(
13751391
base_url=base_url,
13761392
api_key=api_key,
13771393
http_client=http_client,
13781394
_strict_response_validation=True,
13791395
) as client:
13801396
_cache_browser(client)
1381-
with pytest.raises(APIConnectionError):
1397+
with pytest.raises(expected_error):
13821398
client.browsers.fs.write_file("sess-1", _UnseekableFile(b"payload"), path="/tmp/x")
13831399

13841400
assert requests == [
@@ -1395,8 +1411,14 @@ def handle_request(self, request: httpx.Request) -> httpx.Response:
13951411

13961412

13971413
@pytest.mark.asyncio
1414+
@pytest.mark.parametrize(
1415+
("error_type", "expected_error"),
1416+
[(httpx.ReadError, APIConnectionError), (httpx.ReadTimeout, APITimeoutError)],
1417+
)
13981418
async def test_async_stale_direct_vm_auth_body_read_failure_does_not_retry_unreplayable_write(
13991419
monkeypatch: pytest.MonkeyPatch,
1420+
error_type: type[httpx.TransportError],
1421+
expected_error: type[Exception],
14001422
) -> None:
14011423
monkeypatch.delenv("KERNEL_BROWSER_ROUTING_SUBRESOURCES", raising=False)
14021424
monkeypatch.setattr("kernel._base_client.AsyncAPIClient._sleep_for_retry", _skip_async_retry_sleep)
@@ -1406,9 +1428,12 @@ async def handle_request(request: httpx.Request) -> httpx.Response:
14061428
body = b"".join([chunk async for chunk in cast(AsyncIterator[bytes], request.stream)])
14071429
requests.append((request.url, body))
14081430
if "browser-session.test" in str(request.url):
1409-
return httpx.Response(403, stream=_FailingAsyncStream(), headers={"content-type": "text/plain"})
1431+
return httpx.Response(403, stream=_FailingAsyncStream(error_type), headers={"content-type": "text/plain"})
14101432
return httpx.Response(201)
14111433

1434+
async def read_request(request: httpx.Request) -> None:
1435+
await request.aread()
1436+
14121437
async def payload() -> AsyncIterator[bytes]:
14131438
yield b"payload"
14141439

@@ -1417,7 +1442,7 @@ class Transport(httpx.AsyncBaseTransport):
14171442
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
14181443
return await handle_request(request)
14191444

1420-
http_client = httpx.AsyncClient(transport=Transport())
1445+
http_client = httpx.AsyncClient(transport=Transport(), event_hooks={"request": [read_request]})
14211446
async with AsyncKernel(
14221447
base_url=base_url,
14231448
api_key=api_key,
@@ -1427,7 +1452,7 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
14271452
route = browser_route_from_browser(_fake_browser())
14281453
assert route is not None
14291454
client.browser_route_cache.set(route)
1430-
with pytest.raises(APIConnectionError):
1455+
with pytest.raises(expected_error):
14311456
await client.browsers.fs.write_file("sess-1", cast(Any, payload()), path="/tmp/x")
14321457

14331458
assert requests == [

0 commit comments

Comments
 (0)