Skip to content

Commit 8174c85

Browse files
committed
Harden audit log downloads
1 parent 18aa8c9 commit 8174c85

3 files changed

Lines changed: 144 additions & 41 deletions

File tree

src/kernel/lib/audit_log_download.py

Lines changed: 46 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@
22

33
import time
44
import hashlib
5+
import inspect
56
from typing import BinaryIO, Callable, Optional, Protocol, Awaitable
67
from dataclasses import dataclass
78

89
import anyio
910
import httpx
11+
from anyio import to_thread
1012

11-
from .._exceptions import KernelError, APIStatusError
13+
from .._exceptions import KernelError
1214

13-
_DOWNLOAD_ATTEMPTS = 7
15+
_DEFAULT_MAX_TRANSFER_RETRIES = 6
1416
_MAX_RETRY_DELAY = 8.0
1517

1618

@@ -51,22 +53,30 @@ async def close(self) -> None: ...
5153
SyncFetchChunk = Callable[[Optional[str]], _SyncChunkResponse]
5254
AsyncFetchChunk = Callable[[Optional[str]], Awaitable[_AsyncChunkResponse]]
5355
ProgressCallback = Callable[[AuditLogDownloadProgress], None]
56+
AsyncProgressCallback = Callable[[AuditLogDownloadProgress], Optional[Awaitable[None]]]
5457

5558

5659
def download_audit_logs(
5760
fetch_chunk: SyncFetchChunk,
5861
destination: BinaryIO,
5962
*,
6063
on_progress: ProgressCallback | None = None,
64+
max_transfer_retries: int = _DEFAULT_MAX_TRANSFER_RETRIES,
6165
) -> AuditLogDownloadResult:
6266
if not callable(getattr(destination, "write", None)):
6367
raise TypeError("audit log download destination must provide write()")
68+
_validate_max_transfer_retries(max_transfer_retries)
6469

6570
cursor: str | None = None
6671
result = AuditLogDownloadResult(bytes_written=0, chunks=0, rows=0)
72+
seen_cursors: set[str] = set()
6773
while True:
68-
body, headers = _fetch_verified_chunk(fetch_chunk, cursor)
74+
body, headers = _fetch_verified_chunk(fetch_chunk, cursor, max_transfer_retries)
6975
chunk_rows, next_cursor, has_more = _parse_chunk_headers(headers, cursor)
76+
if has_more and next_cursor is not None:
77+
if next_cursor in seen_cursors:
78+
raise AuditLogDownloadError("response repeated X-Next-Cursor header")
79+
seen_cursors.add(next_cursor)
7080
_write_chunk(destination, body)
7181

7282
cursor = next_cursor
@@ -92,17 +102,24 @@ async def async_download_audit_logs(
92102
fetch_chunk: AsyncFetchChunk,
93103
destination: BinaryIO,
94104
*,
95-
on_progress: ProgressCallback | None = None,
105+
on_progress: AsyncProgressCallback | None = None,
106+
max_transfer_retries: int = _DEFAULT_MAX_TRANSFER_RETRIES,
96107
) -> AuditLogDownloadResult:
97108
if not callable(getattr(destination, "write", None)):
98109
raise TypeError("audit log download destination must provide write()")
110+
_validate_max_transfer_retries(max_transfer_retries)
99111

100112
cursor: str | None = None
101113
result = AuditLogDownloadResult(bytes_written=0, chunks=0, rows=0)
114+
seen_cursors: set[str] = set()
102115
while True:
103-
body, headers = await _async_fetch_verified_chunk(fetch_chunk, cursor)
116+
body, headers = await _async_fetch_verified_chunk(fetch_chunk, cursor, max_transfer_retries)
104117
chunk_rows, next_cursor, has_more = _parse_chunk_headers(headers, cursor)
105-
_write_chunk(destination, body)
118+
if has_more and next_cursor is not None:
119+
if next_cursor in seen_cursors:
120+
raise AuditLogDownloadError("response repeated X-Next-Cursor header")
121+
seen_cursors.add(next_cursor)
122+
await to_thread.run_sync(_write_chunk, destination, body)
106123

107124
cursor = next_cursor
108125
result = AuditLogDownloadResult(
@@ -111,51 +128,57 @@ async def async_download_audit_logs(
111128
rows=result.rows + chunk_rows,
112129
)
113130
if on_progress is not None:
114-
on_progress(
131+
callback_result = on_progress(
115132
AuditLogDownloadProgress(
116133
bytes_written=result.bytes_written,
117134
chunks=result.chunks,
118135
rows=result.rows,
119136
chunk_rows=chunk_rows,
120137
)
121138
)
139+
if inspect.isawaitable(callback_result):
140+
await callback_result
122141
if not has_more:
123142
return result
124143

125144

126-
def _fetch_verified_chunk(fetch_chunk: SyncFetchChunk, cursor: str | None) -> tuple[bytes, httpx.Headers]:
127-
for attempt in range(1, _DOWNLOAD_ATTEMPTS + 1):
145+
def _fetch_verified_chunk(
146+
fetch_chunk: SyncFetchChunk, cursor: str | None, max_transfer_retries: int
147+
) -> tuple[bytes, httpx.Headers]:
148+
for retries in range(max_transfer_retries + 1):
149+
response = fetch_chunk(cursor)
128150
try:
129-
response = fetch_chunk(cursor)
130151
try:
131152
body = response.read()
132153
headers = httpx.Headers(response.headers)
133154
finally:
134155
response.close()
135156
_verify_checksum(body, headers)
136157
return body, headers
137-
except Exception as error:
138-
if attempt == _DOWNLOAD_ATTEMPTS or not _is_retryable(error):
158+
except Exception:
159+
if retries == max_transfer_retries:
139160
raise
140-
time.sleep(min(2 ** (attempt - 1), _MAX_RETRY_DELAY))
161+
time.sleep(min(2**retries, _MAX_RETRY_DELAY))
141162
raise AssertionError("unreachable")
142163

143164

144-
async def _async_fetch_verified_chunk(fetch_chunk: AsyncFetchChunk, cursor: str | None) -> tuple[bytes, httpx.Headers]:
145-
for attempt in range(1, _DOWNLOAD_ATTEMPTS + 1):
165+
async def _async_fetch_verified_chunk(
166+
fetch_chunk: AsyncFetchChunk, cursor: str | None, max_transfer_retries: int
167+
) -> tuple[bytes, httpx.Headers]:
168+
for retries in range(max_transfer_retries + 1):
169+
response = await fetch_chunk(cursor)
146170
try:
147-
response = await fetch_chunk(cursor)
148171
try:
149172
body = await response.read()
150173
headers = httpx.Headers(response.headers)
151174
finally:
152175
await response.close()
153-
_verify_checksum(body, headers)
176+
await to_thread.run_sync(_verify_checksum, body, headers)
154177
return body, headers
155-
except Exception as error:
156-
if attempt == _DOWNLOAD_ATTEMPTS or not _is_retryable(error):
178+
except Exception:
179+
if retries == max_transfer_retries:
157180
raise
158-
await anyio.sleep(min(2 ** (attempt - 1), _MAX_RETRY_DELAY))
181+
await anyio.sleep(min(2**retries, _MAX_RETRY_DELAY))
159182
raise AssertionError("unreachable")
160183

161184

@@ -199,7 +222,6 @@ def _write_chunk(destination: BinaryIO, body: bytes) -> None:
199222
remaining = remaining[written:]
200223

201224

202-
def _is_retryable(error: Exception) -> bool:
203-
if isinstance(error, APIStatusError):
204-
return error.status_code == 429 or error.status_code >= 500
205-
return True
225+
def _validate_max_transfer_retries(max_transfer_retries: int) -> None:
226+
if max_transfer_retries < 0:
227+
raise ValueError("max_transfer_retries must be non-negative")

src/kernel/resources/audit_logs.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from ..types.audit_log_entry import AuditLogEntry
3333
from ..lib.audit_log_download import (
3434
ProgressCallback,
35+
AsyncProgressCallback,
3536
AuditLogDownloadResult,
3637
download_audit_logs,
3738
async_download_audit_logs,
@@ -243,6 +244,7 @@ def download(
243244
search_user_id: SequenceNotStr[str] | Omit = omit,
244245
service: str | Omit = omit,
245246
on_progress: ProgressCallback | None = None,
247+
max_transfer_retries: int = 6,
246248
extra_headers: Headers | None = None,
247249
extra_query: Query | None = None,
248250
extra_body: Body | None = None,
@@ -251,13 +253,13 @@ def download(
251253
"""Download a complete audit log export to a writable binary destination.
252254
253255
The SDK verifies every chunk and retries transient transfer failures. It
254-
does not close the destination.
256+
does not close the destination. If the download fails, the destination
257+
may contain a partial export; use a temporary file and atomic rename
258+
when the completed export must be published atomically.
255259
"""
256260

257-
resource = self._client.with_options(max_retries=0).audit_logs
258-
259261
def fetch_chunk(cursor: str | None) -> BinaryAPIResponse:
260-
return resource.export_chunk(
262+
return self.export_chunk(
261263
end=end,
262264
start=start,
263265
auth_strategy=auth_strategy,
@@ -275,7 +277,12 @@ def fetch_chunk(cursor: str | None) -> BinaryAPIResponse:
275277
timeout=timeout,
276278
)
277279

278-
return download_audit_logs(fetch_chunk, to, on_progress=on_progress)
280+
return download_audit_logs(
281+
fetch_chunk,
282+
to,
283+
on_progress=on_progress,
284+
max_transfer_retries=max_transfer_retries,
285+
)
279286

280287

281288
class AsyncAuditLogsResource(AsyncAPIResource):
@@ -480,7 +487,8 @@ async def download(
480487
search: str | Omit = omit,
481488
search_user_id: SequenceNotStr[str] | Omit = omit,
482489
service: str | Omit = omit,
483-
on_progress: ProgressCallback | None = None,
490+
on_progress: AsyncProgressCallback | None = None,
491+
max_transfer_retries: int = 6,
484492
extra_headers: Headers | None = None,
485493
extra_query: Query | None = None,
486494
extra_body: Body | None = None,
@@ -489,13 +497,13 @@ async def download(
489497
"""Download a complete audit log export to a writable binary destination.
490498
491499
The SDK verifies every chunk and retries transient transfer failures. It
492-
does not close the destination.
500+
does not close the destination. If the download fails, the destination
501+
may contain a partial export; use a temporary file and atomic rename
502+
when the completed export must be published atomically.
493503
"""
494504

495-
resource = self._client.with_options(max_retries=0).audit_logs
496-
497505
async def fetch_chunk(cursor: str | None) -> AsyncBinaryAPIResponse:
498-
return await resource.export_chunk(
506+
return await self.export_chunk(
499507
end=end,
500508
start=start,
501509
auth_strategy=auth_strategy,
@@ -513,7 +521,12 @@ async def fetch_chunk(cursor: str | None) -> AsyncBinaryAPIResponse:
513521
timeout=timeout,
514522
)
515523

516-
return await async_download_audit_logs(fetch_chunk, to, on_progress=on_progress)
524+
return await async_download_audit_logs(
525+
fetch_chunk,
526+
to,
527+
on_progress=on_progress,
528+
max_transfer_retries=max_transfer_retries,
529+
)
517530

518531

519532
class AuditLogsResourceWithRawResponse:

tests/test_audit_log_download.py

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
from __future__ import annotations
22

33
import hashlib
4+
import threading
45
from io import BytesIO
6+
from typing import BinaryIO
57

68
import httpx
79
import pytest
810
from respx import MockRouter
911

10-
from kernel import Kernel, AsyncKernel, AuditLogDownloadError
12+
from kernel import Kernel, AsyncKernel, InternalServerError, AuditLogDownloadError
1113
from kernel.lib import audit_log_download
1214

1315

@@ -57,25 +59,43 @@ def respond(request: httpx.Request) -> httpx.Response:
5759
assert cursors == [None, "next"]
5860

5961

60-
async def test_async_download_writes_verified_chunks(async_client: AsyncKernel, respx_mock: MockRouter) -> None:
62+
async def test_async_download_writes_verified_chunks(
63+
async_client: AsyncKernel, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch
64+
) -> None:
6165
respx_mock.get("/audit-logs/export/chunk").mock(
6266
side_effect=[
6367
chunk_response(b"first", rows=2, has_more=True, next_cursor="next"),
6468
chunk_response(b"second", rows=1, has_more=False),
6569
]
6670
)
71+
thread_ids: list[int] = []
72+
write_chunk = audit_log_download._write_chunk
73+
74+
def record_write(destination: BinaryIO, body: bytes) -> None:
75+
thread_ids.append(threading.get_ident())
76+
write_chunk(destination, body)
77+
78+
monkeypatch.setattr(audit_log_download, "_write_chunk", record_write)
6779
destination = BytesIO()
80+
progress_called = False
81+
82+
async def on_progress(_: object) -> None:
83+
nonlocal progress_called
84+
progress_called = True
6885

6986
result = await async_client.audit_logs.download(
7087
to=destination,
7188
start="2026-06-01T00:00:00Z",
7289
end="2026-06-02T00:00:00Z",
90+
on_progress=on_progress,
7391
)
7492

7593
assert destination.getvalue() == b"firstsecond"
7694
assert result.bytes_written == 11
7795
assert result.chunks == 2
7896
assert result.rows == 3
97+
assert progress_called
98+
assert thread_ids and all(thread_id != threading.get_ident() for thread_id in thread_ids)
7999

80100

81101
def test_download_retries_checksum_mismatch(
@@ -102,9 +122,7 @@ def no_sleep(_: float) -> None:
102122
assert route.call_count == 2
103123

104124

105-
def test_download_owns_http_retries(client: Kernel, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch) -> None:
106-
delays: list[float] = []
107-
monkeypatch.setattr(audit_log_download.time, "sleep", delays.append)
125+
def test_download_uses_client_http_retries(client: Kernel, respx_mock: MockRouter) -> None:
108126
route = respx_mock.get("/audit-logs/export/chunk").mock(
109127
side_effect=[
110128
httpx.Response(500, json={"message": "temporary failure"}),
@@ -119,7 +137,57 @@ def test_download_owns_http_retries(client: Kernel, respx_mock: MockRouter, monk
119137
)
120138

121139
assert route.call_count == 2
122-
assert delays == [1]
140+
141+
142+
def test_download_respects_disabled_http_retries(client: Kernel, respx_mock: MockRouter) -> None:
143+
route = respx_mock.get("/audit-logs/export/chunk").mock(
144+
return_value=httpx.Response(500, json={"message": "temporary failure"})
145+
)
146+
147+
with pytest.raises(InternalServerError, match="500"):
148+
client.with_options(max_retries=0).audit_logs.download(
149+
to=BytesIO(),
150+
start="2026-06-01T00:00:00Z",
151+
end="2026-06-02T00:00:00Z",
152+
)
153+
154+
assert route.call_count == 1
155+
156+
157+
def test_download_respects_transfer_retry_limit(client: Kernel, respx_mock: MockRouter) -> None:
158+
bad = chunk_response(b"bad", rows=1, has_more=False)
159+
bad.headers["x-content-sha256"] = hashlib.sha256(b"good").hexdigest()
160+
route = respx_mock.get("/audit-logs/export/chunk").mock(return_value=bad)
161+
162+
with pytest.raises(AuditLogDownloadError, match="checksum mismatch"):
163+
client.audit_logs.download(
164+
to=BytesIO(),
165+
start="2026-06-01T00:00:00Z",
166+
end="2026-06-02T00:00:00Z",
167+
max_transfer_retries=0,
168+
)
169+
170+
assert route.call_count == 1
171+
172+
173+
def test_download_rejects_cursor_cycle(client: Kernel, respx_mock: MockRouter) -> None:
174+
respx_mock.get("/audit-logs/export/chunk").mock(
175+
side_effect=[
176+
chunk_response(b"first", rows=1, has_more=True, next_cursor="a"),
177+
chunk_response(b"second", rows=1, has_more=True, next_cursor="b"),
178+
chunk_response(b"duplicate", rows=1, has_more=True, next_cursor="a"),
179+
]
180+
)
181+
destination = BytesIO()
182+
183+
with pytest.raises(AuditLogDownloadError, match="response repeated X-Next-Cursor header"):
184+
client.audit_logs.download(
185+
to=destination,
186+
start="2026-06-01T00:00:00Z",
187+
end="2026-06-02T00:00:00Z",
188+
)
189+
190+
assert destination.getvalue() == b"firstsecond"
123191

124192

125193
def test_download_rejects_invalid_cursor_before_writing(client: Kernel, respx_mock: MockRouter) -> None:

0 commit comments

Comments
 (0)