Skip to content

Commit 7d2a8f9

Browse files
committed
Address review: narrow multipart change, prove rewinds, always evict
Scope the indexed multipart array names to fs.upload instead of changing the client's generic array encoding: the endpoint now flattens its own body with indexed names and asks extract_files for matching file part names, so load_extensions and any other multipart array keep their existing wire format. Prove a multipart file field can be rewound before treating a stale-JWT failure as retryable. A wrapper can report seekable() while seek() raises, which rendered the fallback body as an empty part. Evict a stale direct-to-VM route from the terminal error path too. Retry eligibility is only consulted when retries remain, so with max_retries=0 a VM 401/403 previously left the dead route cached and wedged later calls. Route only logs/stream rather than the whole logs subresource.
1 parent 001adea commit 7d2a8f9

9 files changed

Lines changed: 396 additions & 38 deletions

File tree

src/kernel/_base_client.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -587,10 +587,7 @@ def _serialize_multipartform(self, data: Mapping[object, object]) -> dict[str, o
587587
# TODO: type ignore is required as stringify_items is well typed but we can't be
588588
# well typed without heavy validation.
589589
data, # type: ignore
590-
# Indexed names (`files[0][dest_path]`) keep each array entry's fields
591-
# grouped together; repeated `files[][dest_path]` parts cannot be
592-
# matched back to their file part. `extract_files` uses the same format.
593-
array_format="indices",
590+
array_format="brackets",
594591
)
595592
serialized: dict[str, object] = {}
596593
for key, value in items:

src/kernel/_client.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,16 +364,28 @@ def _prepare_options(self, options: Any) -> Any:
364364
def _prepare_request(self, request: httpx.Request) -> None:
365365
strip_direct_vm_auth(request, cache=self.browser_route_cache)
366366

367+
def _evict_stale_direct_vm_route(self, response: httpx.Response) -> None:
368+
maybe_evict_browser_route_from_response(response, cache=self.browser_route_cache)
369+
367370
@override
368371
def _should_retry(self, response: httpx.Response) -> bool:
369372
if is_stale_direct_vm_auth_response(response):
370-
maybe_evict_browser_route_from_response(response, cache=self.browser_route_cache)
373+
self._evict_stale_direct_vm_route(response)
371374
# The route is evicted either way; only retry when the body can be
372375
# rebuilt, otherwise the caller sees the original auth failure and a
373376
# later call goes to the control plane.
374377
return should_retry_stale_direct_vm_auth(response)
375378
return super()._should_retry(response)
376379

380+
@override
381+
def _make_status_error_from_response(self, response: httpx.Response) -> APIStatusError:
382+
# `_should_retry` never runs when the request has no retries left, so this
383+
# is the only place a stale direct-to-VM route gets evicted before the
384+
# error surfaces to the caller.
385+
if is_stale_direct_vm_auth_response(response):
386+
self._evict_stale_direct_vm_route(response)
387+
return super()._make_status_error_from_response(response)
388+
377389
@override
378390
def _process_response(
379391
self,
@@ -750,16 +762,28 @@ async def _prepare_options(self, options: Any) -> Any:
750762
async def _prepare_request(self, request: httpx.Request) -> None:
751763
strip_direct_vm_auth(request, cache=self.browser_route_cache)
752764

765+
def _evict_stale_direct_vm_route(self, response: httpx.Response) -> None:
766+
maybe_evict_browser_route_from_response(response, cache=self.browser_route_cache)
767+
753768
@override
754769
def _should_retry(self, response: httpx.Response) -> bool:
755770
if is_stale_direct_vm_auth_response(response):
756-
maybe_evict_browser_route_from_response(response, cache=self.browser_route_cache)
771+
self._evict_stale_direct_vm_route(response)
757772
# The route is evicted either way; only retry when the body can be
758773
# rebuilt, otherwise the caller sees the original auth failure and a
759774
# later call goes to the control plane.
760775
return should_retry_stale_direct_vm_auth(response)
761776
return super()._should_retry(response)
762777

778+
@override
779+
def _make_status_error_from_response(self, response: httpx.Response) -> APIStatusError:
780+
# `_should_retry` never runs when the request has no retries left, so this
781+
# is the only place a stale direct-to-VM route gets evicted before the
782+
# error surfaces to the caller.
783+
if is_stale_direct_vm_auth_response(response):
784+
self._evict_stale_direct_vm_route(response)
785+
return super()._make_status_error_from_response(response)
786+
763787
@override
764788
async def _process_response(
765789
self,

src/kernel/_utils/_utils.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,15 @@ def extract_files(
4040
query: Mapping[str, object],
4141
*,
4242
paths: Sequence[Sequence[str]],
43-
array_format: ArrayFormat = "indices",
43+
array_format: ArrayFormat = "brackets",
4444
) -> list[tuple[str, FileTypes]]:
4545
"""Recursively extract files from the given dictionary based on specified paths.
4646
4747
A path may look like this ['foo', 'files', '<array>', 'data'].
4848
4949
``array_format`` controls how ``<array>`` segments contribute to the emitted
5050
field name. Supported values: ``"brackets"`` (``foo[]``), ``"repeat"`` and
51-
``"comma"`` (``foo``), ``"indices"`` (``foo[0]``, ``foo[1]``). Indexed names are
52-
the default so that a file part stays associated with the sibling fields of the
53-
same array entry, which repeated ``foo[]`` names cannot express.
51+
``"comma"`` (``foo``), ``"indices"`` (``foo[0]``, ``foo[1]``).
5452
5553
Note: this mutates the given dictionary.
5654
"""

src/kernel/lib/browser_routing/routing.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def browser_routing_config_from_env() -> BrowserRoutingConfig:
5252
"playwright",
5353
"process",
5454
"fs",
55-
"logs",
55+
"logs/stream",
5656
)
5757
)
5858
if raw.strip() == "":
@@ -237,11 +237,40 @@ def _multipart_field_is_replayable(field: Any) -> bool:
237237
return True
238238
if getattr(file, "closed", False):
239239
return False
240-
if not callable(getattr(file, "seek", None)):
240+
return _rewind_succeeds(file)
241+
242+
243+
def _rewind_succeeds(file: Any) -> bool:
244+
"""Whether the file field can actually be rewound for another render.
245+
246+
`seekable()` is not proof: a wrapper can report True and still raise from
247+
`seek()`, which would render the field as an empty part on the retry. The
248+
only reliable check is to perform the rewind httpx would perform.
249+
"""
250+
seek = getattr(file, "seek", None)
251+
if not callable(seek):
252+
return False
253+
254+
position: object = None
255+
tell = getattr(file, "tell", None)
256+
if callable(tell):
257+
try:
258+
position = tell()
259+
except Exception:
260+
position = None
261+
262+
try:
263+
seek(0)
264+
except Exception:
241265
return False
242-
seekable = getattr(file, "seekable", None)
243-
# httpx rewinds seekable file fields before rendering them again.
244-
return bool(seekable()) if callable(seekable) else True
266+
267+
if isinstance(position, int) and position > 0:
268+
try:
269+
seek(position)
270+
except Exception:
271+
# The field is left rewound, which is where httpx renders it from anyway.
272+
pass
273+
return True
245274

246275

247276
def _session_id_from_browser_delete_path(path: str) -> str | None:

src/kernel/lib/multipart.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from __future__ import annotations
2+
3+
from typing import Mapping, Sequence, cast
4+
5+
from .._utils import is_given
6+
7+
__all__ = ["indexed_multipart_body"]
8+
9+
10+
def indexed_multipart_body(body: object) -> dict[str, object]:
11+
"""Flatten a multipart body so that array entries carry their index.
12+
13+
Endpoints that take an array of objects with a file field need each entry's
14+
fields grouped together: `files[0][dest_path]` pairs with the `files[0][file]`
15+
part, while repeated `files[][dest_path]` names cannot be matched back to
16+
their file. The returned mapping is already flat, so the client's generic
17+
multipart serialization passes the names through untouched and every other
18+
endpoint keeps its existing encoding.
19+
"""
20+
flattened: dict[str, object] = {}
21+
if isinstance(body, Mapping):
22+
for key, value in cast(Mapping[object, object], body).items():
23+
_flatten(str(key), value, flattened)
24+
return flattened
25+
26+
27+
def _flatten(key: str, value: object, out: dict[str, object]) -> None:
28+
if not is_given(value):
29+
return
30+
31+
if isinstance(value, Mapping):
32+
for child_key, child in cast(Mapping[object, object], value).items():
33+
_flatten(f"{key}[{child_key}]", child, out)
34+
return
35+
36+
if isinstance(value, (list, tuple)):
37+
for index, child in enumerate(cast(Sequence[object], value)):
38+
_flatten(f"{key}[{index}]", child, out)
39+
return
40+
41+
out[key] = value

src/kernel/resources/browsers/fs/fs.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
async_to_custom_streamed_response_wrapper,
4949
)
5050
from ...._base_client import make_request_options
51+
from ....lib.multipart import indexed_multipart_body
5152
from ....types.browsers import (
5253
f_move_params,
5354
f_upload_params,
@@ -510,14 +511,19 @@ def upload(
510511
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
511512
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
512513
body = deepcopy_with_paths({"files": files}, [["files", "<array>", "file"]])
513-
extracted_files = extract_files(cast(Mapping[str, object], body), paths=[["files", "<array>", "file"]])
514+
# The remote filesystem pairs each file part with the sibling fields of the
515+
# same array entry, so both halves of the form use indexed names
516+
# (`files[0][file]`, `files[0][dest_path]`).
517+
extracted_files = extract_files(
518+
cast(Mapping[str, object], body), paths=[["files", "<array>", "file"]], array_format="indices"
519+
)
514520
# It should be noted that the actual Content-Type header that will be
515521
# sent to the server will contain a `boundary` parameter, e.g.
516522
# multipart/form-data; boundary=---abc--
517523
extra_headers["Content-Type"] = "multipart/form-data"
518524
return self._post(
519525
path_template("/browsers/{id}/fs/upload", id=id),
520-
body=maybe_transform(body, f_upload_params.FUploadParams),
526+
body=indexed_multipart_body(maybe_transform(body, f_upload_params.FUploadParams)),
521527
files=extracted_files,
522528
options=make_request_options(
523529
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -1073,14 +1079,19 @@ async def upload(
10731079
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
10741080
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
10751081
body = deepcopy_with_paths({"files": files}, [["files", "<array>", "file"]])
1076-
extracted_files = extract_files(cast(Mapping[str, object], body), paths=[["files", "<array>", "file"]])
1082+
# The remote filesystem pairs each file part with the sibling fields of the
1083+
# same array entry, so both halves of the form use indexed names
1084+
# (`files[0][file]`, `files[0][dest_path]`).
1085+
extracted_files = extract_files(
1086+
cast(Mapping[str, object], body), paths=[["files", "<array>", "file"]], array_format="indices"
1087+
)
10771088
# It should be noted that the actual Content-Type header that will be
10781089
# sent to the server will contain a `boundary` parameter, e.g.
10791090
# multipart/form-data; boundary=---abc--
10801091
extra_headers["Content-Type"] = "multipart/form-data"
10811092
return await self._post(
10821093
path_template("/browsers/{id}/fs/upload", id=id),
1083-
body=await async_maybe_transform(body, f_upload_params.FUploadParams),
1094+
body=indexed_multipart_body(await async_maybe_transform(body, f_upload_params.FUploadParams)),
10841095
files=extracted_files,
10851096
options=make_request_options(
10861097
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout

0 commit comments

Comments
 (0)