|
3 | 3 | import hashlib |
4 | 4 | import threading |
5 | 5 | from io import BytesIO |
6 | | -from typing import BinaryIO, cast |
| 6 | +from typing import BinaryIO, Iterator, AsyncIterator, cast |
| 7 | +from typing_extensions import override |
7 | 8 |
|
8 | 9 | import httpx |
9 | 10 | import pytest |
|
13 | 14 | from kernel.lib import audit_log_download |
14 | 15 |
|
15 | 16 |
|
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]: |
17 | 18 | headers = { |
18 | 19 | "x-content-sha256": hashlib.sha256(body).hexdigest(), |
19 | 20 | "x-has-more": str(has_more).lower(), |
20 | 21 | "x-row-count": str(rows), |
21 | 22 | } |
22 | 23 | if next_cursor is not None: |
23 | 24 | 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") |
25 | 48 |
|
26 | 49 |
|
27 | 50 | def test_download_writes_verified_chunks(client: Kernel, respx_mock: MockRouter) -> None: |
@@ -98,6 +121,33 @@ async def on_progress(_: object) -> None: |
98 | 121 | assert thread_ids and all(thread_id != threading.get_ident() for thread_id in thread_ids) |
99 | 122 |
|
100 | 123 |
|
| 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 | + |
101 | 151 | async def test_async_download_retries_checksum_mismatch( |
102 | 152 | async_client: AsyncKernel, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch |
103 | 153 | ) -> None: |
@@ -154,7 +204,7 @@ async def test_async_download_respects_disabled_http_retries(async_client: Async |
154 | 204 | assert route.call_count == 1 |
155 | 205 |
|
156 | 206 |
|
157 | | -@pytest.mark.parametrize("row_count", ["", "1.0", "50001"]) |
| 207 | +@pytest.mark.parametrize("row_count", ["", "1.0", "50001", "9" * 5000]) |
158 | 208 | def test_download_rejects_invalid_row_count(row_count: str, client: Kernel, respx_mock: MockRouter) -> None: |
159 | 209 | response = chunk_response(b"chunk", rows=1, has_more=False) |
160 | 210 | response.headers["x-row-count"] = row_count |
@@ -202,6 +252,33 @@ def test_download_rejects_invalid_transfer_retry_count(max_transfer_retries: obj |
202 | 252 | ) |
203 | 253 |
|
204 | 254 |
|
| 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 | + |
205 | 282 | def test_download_retries_checksum_mismatch( |
206 | 283 | client: Kernel, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch |
207 | 284 | ) -> None: |
|
0 commit comments