Skip to content

Commit d1e92b6

Browse files
authored
feat(uploads): harden multipart uploads to sdk-rust parity (#133)
* feat(uploads): harden multipart upload resilience * test(auth): cover token-exchange body-read retry * feat(uploads): rust parity gaps and upload ergonomics
1 parent f446d04 commit d1e92b6

13 files changed

Lines changed: 3695 additions & 136 deletions

README.md

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -121,18 +121,40 @@ with ApiClient(Configuration(api_key="...", workspace_id="...")) as client:
121121
(`size` is inferred for all three; a file object is read from its current
122122
position to the end). The SDK picks single vs. multipart from the size,
123123
auto-scales the part size, and bounds part concurrency to a peak-memory budget
124-
(override with `part_size` / `max_concurrency` / `part_retry`). Storage `PUT`s go
125-
through a dedicated, header-isolated connection pool, so the SDK's auth and
126-
workspace headers never reach object storage (which would otherwise reject the
127-
upload). Finalize is sent with retries disabled so the exactly-once call is never
128-
accidentally replayed.
129-
130-
Failures surface as a typed hierarchy under `hotdata.uploads.UploadError`:
131-
`StorageError` (storage returned a non-2xx), `StorageTransportError` (the PUT
132-
failed before any response), `MissingETagError`, `MalformedSessionError`, and
133-
`SizeLimitError`. Opening the session or finalizing raises the usual
134-
`hotdata.exceptions.ApiException` — for example a `501` `PRESIGN_UNSUPPORTED`,
135-
meaning the backend cannot issue upload URLs.
124+
(override with `part_size` / `max_concurrency` / `part_retry`). A file larger than
125+
8 MiB uploads via a **streaming** session — the SDK mints each part's URL just
126+
before its `PUT`, so a presigned URL can't expire mid-transfer on a slow upload.
127+
Storage `PUT`s go through a dedicated, header-isolated connection pool (auth and
128+
workspace headers never reach object storage, which would otherwise reject the
129+
upload) with a 30s connect timeout so a dead endpoint fails fast. Finalize is sent
130+
with retries disabled so the exactly-once call is never accidentally replayed.
131+
132+
Every failure is a subclass of `hotdata.uploads.UploadError` (also importable as
133+
`from hotdata import UploadError`), so a single `except UploadError` catches the
134+
whole flow: `SessionCreateError` (opening the session — check `.status` for a
135+
`501` `PRESIGN_UNSUPPORTED`), `StorageError` (storage returned a non-2xx; `.exhausted`
136+
is `True` if it outlived every retry round), `StorageTransportError` (the PUT
137+
failed before any response), `MissingETagError`, `MintPartError` (minting a part
138+
URL), `FinalizeError`, `MalformedSessionError`, `SizeLimitError`, and
139+
`UploadCancelledError`. The phase errors that wrap a control-plane call chain the
140+
underlying `hotdata.exceptions.ApiException` as `__cause__` (and expose it as
141+
`.api_exception` / `.status`). A local file read error surfaces as `OSError`.
142+
143+
The `progress` callback receives a **cumulative** `(bytes_done, total)` — for a
144+
tqdm bar (whose `update(n)` wants a delta, and which isn't thread-safe under
145+
multipart) use the ready-made adapter:
146+
147+
```python
148+
from hotdata import tqdm_progress
149+
from tqdm import tqdm
150+
151+
with tqdm(total=size, unit="B", unit_scale=True) as bar:
152+
uploads.upload_file("data.parquet", progress=tqdm_progress(bar))
153+
```
154+
155+
Pass a `threading.Event` as `cancel_event` to abort an in-flight upload; tune the
156+
control-plane calls with `request_timeout` (storage-PUT timeouts are automatic and
157+
size-scaled).
136158

137159
For that fallback (or to upload from a non-seekable stream), use `upload_stream`,
138160
which sends the bytes to the legacy `POST /v1/files` endpoint in one request,

hotdata/__init__.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,3 +334,38 @@
334334
from hotdata.query import QueryApi as QueryApi # noqa: E402,F811
335335
from hotdata.arrow import ResultsApi as ResultsApi # noqa: E402,F811
336336
from hotdata.uploads import UploadsApi as UploadsApi # noqa: E402,F811
337+
# The upload error hierarchy + option/helper types, so `from hotdata import
338+
# UploadError` / `StorageError` / ... works (they otherwise live only under
339+
# hotdata.uploads and are invisible to autocomplete on `hotdata.`).
340+
from hotdata.uploads import ( # noqa: E402,F401
341+
UploadError as UploadError,
342+
SessionCreateError as SessionCreateError,
343+
MalformedSessionError as MalformedSessionError,
344+
SizeLimitError as SizeLimitError,
345+
StorageError as StorageError,
346+
StorageTransportError as StorageTransportError,
347+
MissingETagError as MissingETagError,
348+
MintPartError as MintPartError,
349+
FinalizeError as FinalizeError,
350+
UploadCancelledError as UploadCancelledError,
351+
UploadOptions as UploadOptions,
352+
UploadProgress as UploadProgress,
353+
UploadSource as UploadSource,
354+
tqdm_progress as tqdm_progress,
355+
)
356+
__all__ += [ # noqa: F405
357+
"UploadError",
358+
"SessionCreateError",
359+
"MalformedSessionError",
360+
"SizeLimitError",
361+
"StorageError",
362+
"StorageTransportError",
363+
"MissingETagError",
364+
"MintPartError",
365+
"FinalizeError",
366+
"UploadCancelledError",
367+
"UploadOptions",
368+
"UploadProgress",
369+
"UploadSource",
370+
"tqdm_progress",
371+
]

0 commit comments

Comments
 (0)