Skip to content

Commit 386bd06

Browse files
authored
fix(storage): preserve signed upload headers and isolate credentials (#13)
* fix(sdk): preserve signed uploads and add stdin helpers * refactor(tests): isolate storage changes and consolidate upload coverage
1 parent 3388fd6 commit 386bd06

12 files changed

Lines changed: 314 additions & 239 deletions

‎README-SDK.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,17 @@ snapshot.delete()
585585

586586
### StorageObject
587587

588+
Uploads retain the create response's signed URL **and** required headers (including
589+
Azure's `x-ms-blob-type`). All upload helpers use those instructions before marking
590+
the object complete. Treat both as credentials; retrieval does not refresh them.
591+
592+
Signed uploads reuse the configured HTTP transport but exclude client-level default
593+
headers, cookies, and authentication. Custom HTTP transports and event hooks must
594+
not add API credentials to storage requests. Redirects and SDK-level upload retries
595+
are disabled. Upload exception messages omit signed credentials and storage response
596+
bodies; HTTPX request/response objects and third-party debug logging can still contain
597+
sensitive data and must not be logged indiscriminately.
598+
588599
Object-oriented interface for working with storage objects. Created via `runloop.storage_object.create()` or `runloop.storage_object.from_id()`:
589600

590601
```python

‎src/runloop_api_client/sdk/async_.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ async def create(
338338
:rtype: AsyncStorageObject
339339
"""
340340
obj = await self._client.objects.create(**params)
341-
return AsyncStorageObject(self._client, obj.id, upload_url=obj.upload_url)
341+
return AsyncStorageObject(self._client, obj.id, upload_url=obj.upload_url, upload_headers=obj.upload_headers)
342342

343343
def from_id(self, object_id: str) -> AsyncStorageObject:
344344
"""Return a storage object wrapper by identifier.

‎src/runloop_api_client/sdk/async_storage_object.py‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
from __future__ import annotations
44

5-
from typing import Iterable
5+
from typing import Mapping, Iterable
66
from typing_extensions import Unpack, override
77

8+
import httpx
9+
810
from ._types import BaseRequestOptions, LongRequestOptions, SDKObjectDownloadParams
911
from .._client import AsyncRunloop
1012
from ..types.object_view import ObjectView
@@ -15,7 +17,13 @@
1517
class AsyncStorageObject:
1618
"""Async wrapper around storage object operations, including uploads and downloads."""
1719

18-
def __init__(self, client: AsyncRunloop, object_id: str, upload_url: str | None) -> None:
20+
def __init__(
21+
self,
22+
client: AsyncRunloop,
23+
object_id: str,
24+
upload_url: str | None,
25+
upload_headers: Mapping[str, str] | None = None,
26+
) -> None:
1927
"""Initialize the wrapper.
2028
2129
:param client: Generated AsyncRunloop client
@@ -24,10 +32,12 @@ def __init__(self, client: AsyncRunloop, object_id: str, upload_url: str | None)
2432
:type object_id: str
2533
:param upload_url: Optional pre-signed upload URL if the object is still open, defaults to None
2634
:type upload_url: str | None, optional
35+
:param upload_headers: Required signed-upload headers returned with the URL; copied on construction
2736
"""
2837
self._client = client
2938
self._id = object_id
3039
self._upload_url = upload_url
40+
self._upload_headers = dict(upload_headers or {})
3141

3242
@override
3343
def __repr__(self) -> str:
@@ -81,6 +91,7 @@ async def complete(
8191
**options,
8292
)
8393
self._upload_url = None
94+
self._upload_headers = {}
8495
return result
8596

8697
async def get_download_url(
@@ -151,16 +162,30 @@ async def delete(
151162
async def upload_content(self, content: str | bytes | Iterable[bytes]) -> None:
152163
"""Upload content to the object's pre-signed URL.
153164
165+
The URL and required headers are sensitive upload credentials. Requests use
166+
the configured HTTP transport, but not its default headers, cookies, or auth.
167+
Custom transport implementations and event hooks must preserve this isolation.
168+
Uploads are not retried and redirects are not followed.
169+
154170
:param content: Bytes payload, text payload, or an iterable streaming bytes
155171
:type content: str | bytes | Iterable[bytes]
156172
:return: None
157173
:rtype: None
158174
:raises RuntimeError: If no upload URL is available
159-
:raises httpx.HTTPStatusError: Propagated from the underlying ``httpx`` client when the upload fails
175+
:raises httpx.HTTPStatusError: If storage rejects the upload (message excludes signed credentials)
176+
:raises httpx.RequestError: If the upload cannot be sent
160177
"""
161178
url = self._ensure_upload_url()
162-
response = await self._client._client.put(url, content=content)
163-
response.raise_for_status()
179+
# Construct directly so HTTPX does not merge API-client defaults into storage requests.
180+
request = httpx.Request("PUT", url, content=content, headers=self._upload_headers)
181+
try:
182+
response = await self._client._client.send(request, auth=None, follow_redirects=False)
183+
except httpx.RequestError:
184+
raise httpx.RequestError("Storage upload failed during transport", request=request) from None
185+
if not response.is_success:
186+
raise httpx.HTTPStatusError(
187+
f"Storage upload failed (HTTP {response.status_code})", request=request, response=response
188+
)
164189

165190
def as_build_context(self) -> BuildContext:
166191
"""Return this object in the shape expected for a Blueprint build context.

‎src/runloop_api_client/sdk/storage_object.py‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
from __future__ import annotations
44

5-
from typing import Iterable
5+
from typing import Mapping, Iterable
66
from typing_extensions import Unpack, override
77

8+
import httpx
9+
810
from ._types import BaseRequestOptions, LongRequestOptions, SDKObjectDownloadParams
911
from .._client import Runloop
1012
from ..types.object_view import ObjectView
@@ -15,7 +17,13 @@
1517
class StorageObject:
1618
"""Wrapper around storage object operations, including uploads and downloads."""
1719

18-
def __init__(self, client: Runloop, object_id: str, upload_url: str | None) -> None:
20+
def __init__(
21+
self,
22+
client: Runloop,
23+
object_id: str,
24+
upload_url: str | None,
25+
upload_headers: Mapping[str, str] | None = None,
26+
) -> None:
1927
"""Initialize the wrapper.
2028
2129
:param client: Generated Runloop client
@@ -24,10 +32,12 @@ def __init__(self, client: Runloop, object_id: str, upload_url: str | None) -> N
2432
:type object_id: str
2533
:param upload_url: Pre-signed upload URL, if the object is in draft state, defaults to None
2634
:type upload_url: str | None, optional
35+
:param upload_headers: Required signed-upload headers returned with the URL; copied on construction
2736
"""
2837
self._client = client
2938
self._id = object_id
3039
self._upload_url = upload_url
40+
self._upload_headers = dict(upload_headers or {})
3141

3242
@override
3343
def __repr__(self) -> str:
@@ -81,6 +91,7 @@ def complete(
8191
**options,
8292
)
8393
self._upload_url = None
94+
self._upload_headers = {}
8495
return result
8596

8697
def get_download_url(
@@ -151,16 +162,30 @@ def delete(
151162
def upload_content(self, content: str | bytes | Iterable[bytes]) -> None:
152163
"""Upload content to the object's pre-signed URL.
153164
165+
The URL and required headers are sensitive upload credentials. Requests use
166+
the configured HTTP transport, but not its default headers, cookies, or auth.
167+
Custom transport implementations and event hooks must preserve this isolation.
168+
Uploads are not retried and redirects are not followed.
169+
154170
:param content: Bytes payload, text payload, or an iterable streaming bytes
155171
:type content: str | bytes | Iterable[bytes]
156172
:return: None
157173
:rtype: None
158174
:raises RuntimeError: If no upload URL is available
159-
:raises httpx.HTTPStatusError: Propagated from the underlying ``httpx`` client when the upload fails
175+
:raises httpx.HTTPStatusError: If storage rejects the upload (message excludes signed credentials)
176+
:raises httpx.RequestError: If the upload cannot be sent
160177
"""
161178
url = self._ensure_upload_url()
162-
response = self._client._client.put(url, content=content)
163-
response.raise_for_status()
179+
# Construct directly so HTTPX does not merge API-client defaults into storage requests.
180+
request = httpx.Request("PUT", url, content=content, headers=self._upload_headers)
181+
try:
182+
response = self._client._client.send(request, auth=None, follow_redirects=False)
183+
except httpx.RequestError:
184+
raise httpx.RequestError("Storage upload failed during transport", request=request) from None
185+
if not response.is_success:
186+
raise httpx.HTTPStatusError(
187+
f"Storage upload failed (HTTP {response.status_code})", request=request, response=response
188+
)
164189

165190
def as_build_context(self) -> BuildContext:
166191
"""Return this object in the shape expected for a Blueprint build context.

‎src/runloop_api_client/sdk/sync.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ def create(
336336
:rtype: StorageObject
337337
"""
338338
obj = self._client.objects.create(**params)
339-
return StorageObject(self._client, obj.id, upload_url=obj.upload_url)
339+
return StorageObject(self._client, obj.id, upload_url=obj.upload_url, upload_headers=obj.upload_headers)
340340

341341
def from_id(self, object_id: str) -> StorageObject:
342342
"""Return a storage object wrapper by identifier.

‎tests/sdk/conftest.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ class MockObjectView:
8888

8989
id: str = TEST_IDS["object"]
9090
upload_url: str = "https://upload.example.com/obj_123"
91+
upload_headers: dict[str, str] | None = None
9192
name: str = "test-object"
9293

9394

‎tests/sdk/devbox/test_edge_cases.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,11 @@ def test_path_handling(self, mock_client: Mock, tmp_path: Path) -> None:
138138

139139
http_client = Mock()
140140
mock_response = create_mock_httpx_response()
141-
http_client.put.return_value = mock_response
141+
http_client.send.return_value = mock_response
142142
mock_client._client = http_client
143143

144144
obj = StorageObject(mock_client, "obj_123", "https://upload.example.com")
145145
obj.upload_content(temp_file.read_text())
146146
obj.upload_content(temp_file.read_bytes())
147147

148-
assert http_client.put.call_count == 2
148+
assert http_client.send.call_count == 2

‎tests/sdk/test_async_ops.py‎

Lines changed: 17 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,7 @@ async def test_upload_from_file(
402402

403403
http_client = AsyncMock()
404404
mock_response = create_mock_httpx_response()
405-
http_client.put = AsyncMock(return_value=mock_response)
405+
http_client.send = AsyncMock(return_value=mock_response)
406406
mock_async_client._client = http_client
407407

408408
ops = AsyncStorageObjectOps(mock_async_client)
@@ -416,57 +416,8 @@ async def test_upload_from_file(
416416
metadata=None,
417417
ttl_ms=None,
418418
)
419-
http_client.put.assert_awaited_once_with(object_view.upload_url, content=b"test content")
420-
mock_async_client.objects.complete.assert_awaited_once()
421-
422-
@pytest.mark.asyncio
423-
async def test_upload_from_text(self, mock_async_client: AsyncMock, object_view: MockObjectView) -> None:
424-
"""Test upload_from_text method."""
425-
mock_async_client.objects.create = AsyncMock(return_value=object_view)
426-
mock_async_client.objects.complete = AsyncMock(return_value=object_view)
427-
428-
http_client = AsyncMock()
429-
mock_response = create_mock_httpx_response()
430-
http_client.put = AsyncMock(return_value=mock_response)
431-
mock_async_client._client = http_client
432-
433-
ops = AsyncStorageObjectOps(mock_async_client)
434-
obj = await ops.upload_from_text("test content", name="test.txt", metadata={"key": "value"})
435-
436-
assert isinstance(obj, AsyncStorageObject)
437-
assert obj.id == "obj_123"
438-
mock_async_client.objects.create.assert_awaited_once_with(
439-
name="test.txt",
440-
content_type="text",
441-
metadata={"key": "value"},
442-
ttl_ms=None,
443-
)
444-
http_client.put.assert_awaited_once_with(object_view.upload_url, content="test content")
445-
mock_async_client.objects.complete.assert_awaited_once()
446-
447-
@pytest.mark.asyncio
448-
async def test_upload_from_bytes(self, mock_async_client: AsyncMock, object_view: MockObjectView) -> None:
449-
"""Test upload_from_bytes method."""
450-
mock_async_client.objects.create = AsyncMock(return_value=object_view)
451-
mock_async_client.objects.complete = AsyncMock(return_value=object_view)
452-
453-
http_client = AsyncMock()
454-
mock_response = create_mock_httpx_response()
455-
http_client.put = AsyncMock(return_value=mock_response)
456-
mock_async_client._client = http_client
457-
458-
ops = AsyncStorageObjectOps(mock_async_client)
459-
obj = await ops.upload_from_bytes(b"test content", name="test.bin", content_type="binary")
460-
461-
assert isinstance(obj, AsyncStorageObject)
462-
assert obj.id == "obj_123"
463-
mock_async_client.objects.create.assert_awaited_once_with(
464-
name="test.bin",
465-
content_type="binary",
466-
metadata=None,
467-
ttl_ms=None,
468-
)
469-
http_client.put.assert_awaited_once_with(object_view.upload_url, content=b"test content")
419+
assert http_client.send.call_count == 1
420+
assert http_client.send.call_args[0][0].read() == b"test content"
470421
mock_async_client.objects.complete.assert_awaited_once()
471422

472423
@pytest.mark.asyncio
@@ -506,7 +457,7 @@ async def test_upload_from_dir(
506457

507458
http_client = AsyncMock()
508459
mock_response = create_mock_httpx_response()
509-
http_client.put = AsyncMock(return_value=mock_response)
460+
http_client.send = AsyncMock(return_value=mock_response)
510461
mock_async_client._client = http_client
511462

512463
ops = AsyncStorageObjectOps(mock_async_client)
@@ -520,13 +471,13 @@ async def test_upload_from_dir(
520471
metadata={"key": "value"},
521472
ttl_ms=None,
522473
)
523-
# Verify that put was called with tarball content
524-
http_client.put.assert_awaited_once()
525-
call_args = http_client.put.call_args
526-
assert call_args[0][0] == object_view.upload_url
474+
# Verify that a request was sent with tarball content
475+
http_client.send.assert_awaited_once()
476+
call_args = http_client.send.call_args
477+
assert str(call_args[0][0].url) == object_view.upload_url
527478

528479
# Verify it's a valid gzipped tarball
529-
uploaded_content = call_args[1]["content"]
480+
uploaded_content = call_args[0][0].read()
530481
with tarfile.open(fileobj=io.BytesIO(uploaded_content), mode="r:gz") as tar:
531482
members = tar.getmembers()
532483
member_names = [m.name for m in members]
@@ -555,7 +506,7 @@ async def test_upload_from_dir_with_inline_ignore_patterns(
555506

556507
http_client = AsyncMock()
557508
mock_response = create_mock_httpx_response()
558-
http_client.put = AsyncMock(return_value=mock_response)
509+
http_client.send = AsyncMock(return_value=mock_response)
559510
mock_async_client._client = http_client
560511

561512
client = AsyncStorageObjectOps(mock_async_client)
@@ -569,7 +520,7 @@ def ignore_logs_and_build(ti: tarfile.TarInfo) -> tarfile.TarInfo | None:
569520
obj = await client.upload_from_dir(test_dir, ignore=ignore_logs_and_build)
570521

571522
assert isinstance(obj, AsyncStorageObject)
572-
uploaded_content = http_client.put.call_args[1]["content"]
523+
uploaded_content = http_client.send.call_args[0][0].read()
573524

574525
with tarfile.open(fileobj=io.BytesIO(uploaded_content), mode="r:gz") as tar:
575526
names = {m.name for m in tar.getmembers()}
@@ -592,7 +543,7 @@ async def test_upload_from_dir_default_name(
592543

593544
http_client = AsyncMock()
594545
mock_response = create_mock_httpx_response()
595-
http_client.put = AsyncMock(return_value=mock_response)
546+
http_client.send = AsyncMock(return_value=mock_response)
596547
mock_async_client._client = http_client
597548

598549
ops = AsyncStorageObjectOps(mock_async_client)
@@ -623,7 +574,7 @@ async def test_upload_from_dir_with_ttl(
623574

624575
http_client = AsyncMock()
625576
mock_response = create_mock_httpx_response()
626-
http_client.put = AsyncMock(return_value=mock_response)
577+
http_client.send = AsyncMock(return_value=mock_response)
627578
mock_async_client._client = http_client
628579

629580
ops = AsyncStorageObjectOps(mock_async_client)
@@ -650,7 +601,7 @@ async def test_upload_from_dir_empty_directory(
650601

651602
http_client = AsyncMock()
652603
mock_response = create_mock_httpx_response()
653-
http_client.put = AsyncMock(return_value=mock_response)
604+
http_client.send = AsyncMock(return_value=mock_response)
654605
mock_async_client._client = http_client
655606

656607
ops = AsyncStorageObjectOps(mock_async_client)
@@ -664,7 +615,7 @@ async def test_upload_from_dir_empty_directory(
664615
metadata=None,
665616
ttl_ms=None,
666617
)
667-
http_client.put.assert_awaited_once()
618+
http_client.send.assert_awaited_once()
668619
mock_async_client.objects.complete.assert_awaited_once()
669620

670621
@pytest.mark.asyncio
@@ -681,7 +632,7 @@ async def test_upload_from_dir_with_string_path(
681632

682633
http_client = AsyncMock()
683634
mock_response = create_mock_httpx_response()
684-
http_client.put = AsyncMock(return_value=mock_response)
635+
http_client.send = AsyncMock(return_value=mock_response)
685636
mock_async_client._client = http_client
686637

687638
ops = AsyncStorageObjectOps(mock_async_client)
@@ -696,7 +647,7 @@ async def test_upload_from_dir_with_string_path(
696647
metadata=None,
697648
ttl_ms=None,
698649
)
699-
http_client.put.assert_awaited_once()
650+
http_client.send.assert_awaited_once()
700651
mock_async_client.objects.complete.assert_awaited_once()
701652

702653

0 commit comments

Comments
 (0)