Skip to content

Commit e254579

Browse files
committed
Retry streamed audit log transfers
1 parent 39e5555 commit e254579

3 files changed

Lines changed: 117 additions & 33 deletions

File tree

src/kernel/lib/audit_log_download.py

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import time
44
import hashlib
55
import inspect
6-
from typing import BinaryIO, Callable, Optional, Protocol, Awaitable
6+
from typing import BinaryIO, Callable, Optional, Protocol, Awaitable, ContextManager, AsyncContextManager
77
from dataclasses import dataclass
88

99
import anyio
@@ -51,8 +51,8 @@ async def read(self) -> bytes: ...
5151
async def close(self) -> None: ...
5252

5353

54-
SyncFetchChunk = Callable[[Optional[str]], _SyncChunkResponse]
55-
AsyncFetchChunk = Callable[[Optional[str]], Awaitable[_AsyncChunkResponse]]
54+
SyncFetchChunk = Callable[[Optional[str]], ContextManager[_SyncChunkResponse]]
55+
AsyncFetchChunk = Callable[[Optional[str]], AsyncContextManager[_AsyncChunkResponse]]
5656
ProgressCallback = Callable[[AuditLogDownloadProgress], None]
5757
AsyncProgressCallback = Callable[[AuditLogDownloadProgress], Optional[Awaitable[None]]]
5858

@@ -147,39 +147,41 @@ def _fetch_verified_chunk(
147147
fetch_chunk: SyncFetchChunk, cursor: str | None, max_transfer_retries: int
148148
) -> tuple[bytes, httpx.Headers]:
149149
for retries in range(max_transfer_retries + 1):
150-
response = fetch_chunk(cursor)
151-
try:
150+
transfer_error: Exception | None = None
151+
with fetch_chunk(cursor) as response:
152152
try:
153153
body = response.read()
154154
headers = httpx.Headers(response.headers)
155-
finally:
156-
response.close()
157-
_verify_checksum(body, headers)
158-
return body, headers
159-
except Exception:
160-
if retries == max_transfer_retries:
161-
raise
162-
time.sleep(min(2**retries, _MAX_RETRY_DELAY))
155+
_verify_checksum(body, headers)
156+
except Exception as error:
157+
transfer_error = error
158+
else:
159+
return body, headers
160+
assert transfer_error is not None
161+
if retries == max_transfer_retries:
162+
raise transfer_error
163+
time.sleep(min(2**retries, _MAX_RETRY_DELAY))
163164
raise AssertionError("unreachable")
164165

165166

166167
async def _async_fetch_verified_chunk(
167168
fetch_chunk: AsyncFetchChunk, cursor: str | None, max_transfer_retries: int
168169
) -> tuple[bytes, httpx.Headers]:
169170
for retries in range(max_transfer_retries + 1):
170-
response = await fetch_chunk(cursor)
171-
try:
171+
transfer_error: Exception | None = None
172+
async with fetch_chunk(cursor) as response:
172173
try:
173174
body = await response.read()
174175
headers = httpx.Headers(response.headers)
175-
finally:
176-
await response.close()
177-
await to_thread.run_sync(_verify_checksum, body, headers)
178-
return body, headers
179-
except Exception:
180-
if retries == max_transfer_retries:
181-
raise
182-
await anyio.sleep(min(2**retries, _MAX_RETRY_DELAY))
176+
await to_thread.run_sync(_verify_checksum, body, headers)
177+
except Exception as error:
178+
transfer_error = error
179+
else:
180+
return body, headers
181+
assert transfer_error is not None
182+
if retries == max_transfer_retries:
183+
raise transfer_error
184+
await anyio.sleep(min(2**retries, _MAX_RETRY_DELAY))
183185
raise AssertionError("unreachable")
184186

185187

@@ -199,7 +201,12 @@ def _parse_chunk_headers(headers: httpx.Headers, current_cursor: str | None) ->
199201
has_more = has_more_value == "true"
200202

201203
row_count = headers.get("x-row-count")
202-
if row_count is None or not row_count.isascii() or not row_count.isdecimal():
204+
if (
205+
row_count is None
206+
or not row_count.isascii()
207+
or not row_count.isdecimal()
208+
or len(row_count) > len(str(_MAX_CHUNK_ROWS))
209+
):
203210
raise AuditLogDownloadError("response missing or invalid X-Row-Count header")
204211
rows = int(row_count)
205212
if rows > _MAX_CHUNK_ROWS:

src/kernel/resources/audit_logs.py

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

33
from __future__ import annotations
44

5-
from typing import Union, BinaryIO
5+
from typing import Union, BinaryIO, ContextManager, AsyncContextManager
66
from datetime import datetime
77
from typing_extensions import Literal
88

@@ -258,8 +258,8 @@ def download(
258258
when the completed export must be published atomically.
259259
"""
260260

261-
def fetch_chunk(cursor: str | None) -> BinaryAPIResponse:
262-
return self.export_chunk(
261+
def fetch_chunk(cursor: str | None) -> ContextManager[StreamedBinaryAPIResponse]:
262+
return self.with_streaming_response.export_chunk(
263263
end=end,
264264
start=start,
265265
auth_strategy=auth_strategy,
@@ -502,8 +502,8 @@ async def download(
502502
when the completed export must be published atomically.
503503
"""
504504

505-
async def fetch_chunk(cursor: str | None) -> AsyncBinaryAPIResponse:
506-
return await self.export_chunk(
505+
def fetch_chunk(cursor: str | None) -> AsyncContextManager[AsyncStreamedBinaryAPIResponse]:
506+
return self.with_streaming_response.export_chunk(
507507
end=end,
508508
start=start,
509509
auth_strategy=auth_strategy,

tests/test_audit_log_download.py

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import hashlib
44
import threading
55
from io import BytesIO
6-
from typing import BinaryIO, cast
6+
from typing import BinaryIO, Iterator, AsyncIterator, cast
7+
from typing_extensions import override
78

89
import httpx
910
import pytest
@@ -13,15 +14,37 @@
1314
from kernel.lib import audit_log_download
1415

1516

16-
def chunk_response(body: bytes, *, rows: int, has_more: bool, next_cursor: str | None = None) -> httpx.Response:
17+
def chunk_headers(body: bytes, *, rows: int, has_more: bool, next_cursor: str | None = None) -> dict[str, str]:
1718
headers = {
1819
"x-content-sha256": hashlib.sha256(body).hexdigest(),
1920
"x-has-more": str(has_more).lower(),
2021
"x-row-count": str(rows),
2122
}
2223
if next_cursor is not None:
2324
headers["x-next-cursor"] = next_cursor
24-
return httpx.Response(200, content=body, headers=headers)
25+
return headers
26+
27+
28+
def chunk_response(body: bytes, *, rows: int, has_more: bool, next_cursor: str | None = None) -> httpx.Response:
29+
return httpx.Response(
30+
200,
31+
content=body,
32+
headers=chunk_headers(body, rows=rows, has_more=has_more, next_cursor=next_cursor),
33+
)
34+
35+
36+
class BrokenSyncByteStream(httpx.SyncByteStream):
37+
@override
38+
def __iter__(self) -> Iterator[bytes]:
39+
yield b"partial"
40+
raise httpx.ReadError("truncated response body")
41+
42+
43+
class BrokenAsyncByteStream(httpx.AsyncByteStream):
44+
@override
45+
async def __aiter__(self) -> AsyncIterator[bytes]:
46+
yield b"partial"
47+
raise httpx.ReadError("truncated response body")
2548

2649

2750
def test_download_writes_verified_chunks(client: Kernel, respx_mock: MockRouter) -> None:
@@ -98,6 +121,33 @@ async def on_progress(_: object) -> None:
98121
assert thread_ids and all(thread_id != threading.get_ident() for thread_id in thread_ids)
99122

100123

124+
async def test_async_download_retries_body_read_failure(
125+
async_client: AsyncKernel, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch
126+
) -> None:
127+
async def no_sleep(_: float) -> None:
128+
pass
129+
130+
monkeypatch.setattr(audit_log_download.anyio, "sleep", no_sleep)
131+
headers = chunk_headers(b"good", rows=1, has_more=False)
132+
route = respx_mock.get("/audit-logs/export/chunk").mock(
133+
side_effect=[
134+
httpx.Response(200, headers=headers, stream=BrokenAsyncByteStream()),
135+
chunk_response(b"good", rows=1, has_more=False),
136+
]
137+
)
138+
destination = BytesIO()
139+
140+
await async_client.with_options(max_retries=0).audit_logs.download(
141+
to=destination,
142+
start="2026-06-01T00:00:00Z",
143+
end="2026-06-02T00:00:00Z",
144+
max_transfer_retries=1,
145+
)
146+
147+
assert destination.getvalue() == b"good"
148+
assert route.call_count == 2
149+
150+
101151
async def test_async_download_retries_checksum_mismatch(
102152
async_client: AsyncKernel, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch
103153
) -> None:
@@ -154,7 +204,7 @@ async def test_async_download_respects_disabled_http_retries(async_client: Async
154204
assert route.call_count == 1
155205

156206

157-
@pytest.mark.parametrize("row_count", ["", "1.0", "50001"])
207+
@pytest.mark.parametrize("row_count", ["", "1.0", "50001", "9" * 5000])
158208
def test_download_rejects_invalid_row_count(row_count: str, client: Kernel, respx_mock: MockRouter) -> None:
159209
response = chunk_response(b"chunk", rows=1, has_more=False)
160210
response.headers["x-row-count"] = row_count
@@ -202,6 +252,33 @@ def test_download_rejects_invalid_transfer_retry_count(max_transfer_retries: obj
202252
)
203253

204254

255+
def test_download_retries_body_read_failure(
256+
client: Kernel, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch
257+
) -> None:
258+
def no_sleep(_: float) -> None:
259+
pass
260+
261+
monkeypatch.setattr(audit_log_download.time, "sleep", no_sleep)
262+
headers = chunk_headers(b"good", rows=1, has_more=False)
263+
route = respx_mock.get("/audit-logs/export/chunk").mock(
264+
side_effect=[
265+
httpx.Response(200, headers=headers, stream=BrokenSyncByteStream()),
266+
chunk_response(b"good", rows=1, has_more=False),
267+
]
268+
)
269+
destination = BytesIO()
270+
271+
client.with_options(max_retries=0).audit_logs.download(
272+
to=destination,
273+
start="2026-06-01T00:00:00Z",
274+
end="2026-06-02T00:00:00Z",
275+
max_transfer_retries=1,
276+
)
277+
278+
assert destination.getvalue() == b"good"
279+
assert route.call_count == 2
280+
281+
205282
def test_download_retries_checksum_mismatch(
206283
client: Kernel, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch
207284
) -> None:

0 commit comments

Comments
 (0)