22
33import time
44import hashlib
5+ import inspect
56from typing import BinaryIO , Callable , Optional , Protocol , Awaitable
67from dataclasses import dataclass
78
89import anyio
910import 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: ...
5153SyncFetchChunk = Callable [[Optional [str ]], _SyncChunkResponse ]
5254AsyncFetchChunk = Callable [[Optional [str ]], Awaitable [_AsyncChunkResponse ]]
5355ProgressCallback = Callable [[AuditLogDownloadProgress ], None ]
56+ AsyncProgressCallback = Callable [[AuditLogDownloadProgress ], Optional [Awaitable [None ]]]
5457
5558
5659def 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" )
0 commit comments