coding-client 1.21.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coding_client/__init__.py +1 -0
- coding_client/api/__init__.py +19 -0
- coding_client/api/exception_handlers.py +158 -0
- coding_client/api/logging_setup.py +44 -0
- coding_client/api/metrics.py +40 -0
- coding_client/api/openapi_config.py +124 -0
- coding_client/api/request_logging.py +60 -0
- coding_client/api/validation.py +150 -0
- coding_client/auth/__init__.py +78 -0
- coding_client/auth/_default_tenant.py +132 -0
- coding_client/auth/_header_trust.py +98 -0
- coding_client/auth/_onbehalf.py +315 -0
- coding_client/auth/_trusted_networks.py +94 -0
- coding_client/auth/dependencies.py +61 -0
- coding_client/auth/middleware.py +445 -0
- coding_client/auth/models.py +83 -0
- coding_client/auth/outbound.py +137 -0
- coding_client/auth/rbac.py +21 -0
- coding_client/auth/rbac_coding.py +5 -0
- coding_client/auth/tenant.py +650 -0
- coding_client/auth/websocket.py +148 -0
- coding_client/clients/__init__.py +24 -0
- coding_client/clients/base.py +131 -0
- coding_client/clients/coding_ai.py +33 -0
- coding_client/clients/coding_analysis.py +36 -0
- coding_client/clients/coding_containers.py +45 -0
- coding_client/clients/coding_github.py +127 -0
- coding_client/clients/coding_realtime.py +127 -0
- coding_client/clients/coding_security.py +51 -0
- coding_client/clients/coding_testing.py +31 -0
- coding_client/clients/coding_workspaces.py +265 -0
- coding_client/config/__init__.py +12 -0
- coding_client/config/base_settings.py +61 -0
- coding_client/config/yaml_config_loader.py +172 -0
- coding_client/db/__init__.py +42 -0
- coding_client/db/db_types.py +122 -0
- coding_client/db/exceptions.py +59 -0
- coding_client/db/postgres_connection.py +572 -0
- coding_client/db/tenant_query_helper.py +417 -0
- coding_client/db_init.py +49 -0
- coding_client/exceptions/__init__.py +67 -0
- coding_client/exceptions/api_exceptions.py +395 -0
- coding_client/exceptions/pydantic_ai_exceptions.py +533 -0
- coding_client/interfaces/__init__.py +22 -0
- coding_client/interfaces/cache_interface.py +179 -0
- coding_client/interfaces/container_interface.py +49 -0
- coding_client/interfaces/docker_interface.py +283 -0
- coding_client/interfaces/graph_interface.py +28 -0
- coding_client/interfaces/security_interface.py +72 -0
- coding_client/interfaces/webhook_interface.py +67 -0
- coding_client/llm/__init__.py +54 -0
- coding_client/llm/base_agent.py +195 -0
- coding_client/llm/context.py +121 -0
- coding_client/llm/responses.py +102 -0
- coding_client/llm/routing.py +82 -0
- coding_client/llm/settings.py +102 -0
- coding_client/models/__init__.py +62 -0
- coding_client/models/agent.py +38 -0
- coding_client/models/ai_test_failure.py +745 -0
- coding_client/models/analysis.py +330 -0
- coding_client/models/architecture.py +685 -0
- coding_client/models/bug.py +128 -0
- coding_client/models/code_graph.py +317 -0
- coding_client/models/container.py +241 -0
- coding_client/models/core.py +878 -0
- coding_client/models/docker_container.py +104 -0
- coding_client/models/github.py +293 -0
- coding_client/models/logging.py +29 -0
- coding_client/models/package_manager.py +257 -0
- coding_client/models/pr_validation.py +440 -0
- coding_client/models/pydantic_ai.py +395 -0
- coding_client/models/repo_scanner.py +249 -0
- coding_client/models/responses.py +431 -0
- coding_client/models/security.py +185 -0
- coding_client/models/static_analysis.py +508 -0
- coding_client/models/system.py +48 -0
- coding_client/models/test_executor.py +204 -0
- coding_client/models/testing.py +673 -0
- coding_client/models/testing_config.py +200 -0
- coding_client/models/validation.py +127 -0
- coding_client/models_init.py +272 -0
- coding_client/telemetry.py +195 -0
- coding_client/testing.py +180 -0
- coding_client/types/__init__.py +130 -0
- coding_client/types/constraint_types.py +276 -0
- coding_client/types/core_types.py +112 -0
- coding_client/types/state_machine.py +443 -0
- coding_client/utils/__init__.py +86 -0
- coding_client/utils/archive_utils.py +387 -0
- coding_client/utils/ast_debug.py +45 -0
- coding_client/utils/async_timeout.py +364 -0
- coding_client/utils/constants.py +518 -0
- coding_client/utils/content_processing.py +326 -0
- coding_client/utils/database.py +916 -0
- coding_client/utils/encoding_helper.py +222 -0
- coding_client/utils/enum_helpers.py +166 -0
- coding_client/utils/file_operations.py +177 -0
- coding_client/utils/filesystem_ops.py +528 -0
- coding_client/utils/http_client.py +562 -0
- coding_client/utils/path_utils.py +471 -0
- coding_client/utils/profiling.py +421 -0
- coding_client/utils/response_builder.py +240 -0
- coding_client/utils/retry_helper.py +712 -0
- coding_client/utils/security_validation.py +423 -0
- coding_client-1.21.2.dist-info/METADATA +234 -0
- coding_client-1.21.2.dist-info/RECORD +109 -0
- coding_client-1.21.2.dist-info/WHEEL +5 -0
- coding_client-1.21.2.dist-info/licenses/LICENSE +201 -0
- coding_client-1.21.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Retry helper utilities for handling transient failures with backoff.
|
|
3
|
+
|
|
4
|
+
This module provides reusable retry logic using tenacity for:
|
|
5
|
+
- Docker API calls (@retry_docker)
|
|
6
|
+
- Database operations (@retry_database)
|
|
7
|
+
- HTTP/network calls (@retry_network)
|
|
8
|
+
|
|
9
|
+
All decorators support both sync and async functions with configurable
|
|
10
|
+
max_retries, backoff strategies, and exception filtering.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import inspect
|
|
17
|
+
import logging
|
|
18
|
+
import socket
|
|
19
|
+
import sqlite3
|
|
20
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
21
|
+
from functools import wraps
|
|
22
|
+
from typing import Any, ParamSpec, TypeVar, overload
|
|
23
|
+
|
|
24
|
+
from tenacity import (
|
|
25
|
+
AsyncRetrying,
|
|
26
|
+
Retrying,
|
|
27
|
+
retry_if_exception_type,
|
|
28
|
+
stop_after_attempt,
|
|
29
|
+
wait_exponential,
|
|
30
|
+
wait_fixed,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
T = TypeVar("T")
|
|
36
|
+
P = ParamSpec("P")
|
|
37
|
+
R = TypeVar("R")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# =============================================================================
|
|
41
|
+
# Exception Categories for Retry Logic
|
|
42
|
+
# =============================================================================
|
|
43
|
+
|
|
44
|
+
# Docker-related exceptions that are worth retrying
|
|
45
|
+
DOCKER_RETRYABLE_EXCEPTIONS: tuple[type[Exception], ...] = (
|
|
46
|
+
ConnectionError,
|
|
47
|
+
TimeoutError,
|
|
48
|
+
OSError,
|
|
49
|
+
socket.error,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Database-related exceptions that are worth retrying
|
|
53
|
+
DATABASE_RETRYABLE_EXCEPTIONS: tuple[type[Exception], ...] = (
|
|
54
|
+
sqlite3.OperationalError,
|
|
55
|
+
OSError,
|
|
56
|
+
TimeoutError,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Network-related exceptions that are worth retrying
|
|
60
|
+
NETWORK_RETRYABLE_EXCEPTIONS: tuple[type[Exception], ...] = (
|
|
61
|
+
ConnectionError,
|
|
62
|
+
ConnectionRefusedError,
|
|
63
|
+
ConnectionResetError,
|
|
64
|
+
TimeoutError,
|
|
65
|
+
OSError,
|
|
66
|
+
socket.error,
|
|
67
|
+
socket.timeout,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# =============================================================================
|
|
72
|
+
# Logging Callbacks
|
|
73
|
+
# =============================================================================
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _log_retry_attempt(retry_state: Any, operation_type: str = "operation") -> None:
|
|
77
|
+
"""Log retry attempt with context."""
|
|
78
|
+
fn_name = getattr(retry_state.fn, "__name__", "unknown")
|
|
79
|
+
attempt = retry_state.attempt_number
|
|
80
|
+
exception = retry_state.outcome.exception() if retry_state.outcome else None
|
|
81
|
+
|
|
82
|
+
if exception:
|
|
83
|
+
logger.warning(
|
|
84
|
+
f"[RETRY] {operation_type} '{fn_name}' failed (attempt {attempt}): {type(exception).__name__}: {exception}"
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
logger.warning(f"[RETRY] {operation_type} '{fn_name}' retrying (attempt {attempt})")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _log_docker_retry(retry_state: Any) -> None:
|
|
91
|
+
"""Log Docker API retry attempt."""
|
|
92
|
+
_log_retry_attempt(retry_state, "Docker API")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _log_database_retry(retry_state: Any) -> None:
|
|
96
|
+
"""Log database retry attempt."""
|
|
97
|
+
_log_retry_attempt(retry_state, "Database")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _log_network_retry(retry_state: Any) -> None:
|
|
101
|
+
"""Log network retry attempt."""
|
|
102
|
+
_log_retry_attempt(retry_state, "Network")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# =============================================================================
|
|
106
|
+
# Retry Decorators Using Tenacity
|
|
107
|
+
# =============================================================================
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def retry_docker(
|
|
111
|
+
max_attempts: int = 3,
|
|
112
|
+
min_wait: float = 1.0,
|
|
113
|
+
max_wait: float = 10.0,
|
|
114
|
+
multiplier: float = 2.0,
|
|
115
|
+
reraise: bool = True,
|
|
116
|
+
) -> Callable[..., Any]:
|
|
117
|
+
"""
|
|
118
|
+
Decorator for retrying Docker API calls with exponential backoff.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
max_attempts: Maximum number of retry attempts (default: 3)
|
|
122
|
+
min_wait: Minimum wait time between retries in seconds (default: 1.0)
|
|
123
|
+
max_wait: Maximum wait time between retries in seconds (default: 10.0)
|
|
124
|
+
multiplier: Multiplier for exponential backoff (default: 2.0)
|
|
125
|
+
reraise: Whether to reraise the last exception (default: True)
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Decorated function with retry logic
|
|
129
|
+
|
|
130
|
+
Example:
|
|
131
|
+
@retry_docker(max_attempts=5)
|
|
132
|
+
async def pull_image(image_name: str) -> None:
|
|
133
|
+
...
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
@overload
|
|
137
|
+
def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: ...
|
|
138
|
+
@overload
|
|
139
|
+
def decorator(func: Callable[P, R]) -> Callable[P, R]: ...
|
|
140
|
+
def decorator(func: Callable[P, Any]) -> Callable[P, Any]:
|
|
141
|
+
@wraps(func)
|
|
142
|
+
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
|
|
143
|
+
async for attempt in AsyncRetrying(
|
|
144
|
+
stop=stop_after_attempt(max_attempts),
|
|
145
|
+
wait=wait_exponential(multiplier=multiplier, min=min_wait, max=max_wait),
|
|
146
|
+
retry=retry_if_exception_type(DOCKER_RETRYABLE_EXCEPTIONS),
|
|
147
|
+
before_sleep=_log_docker_retry,
|
|
148
|
+
reraise=reraise,
|
|
149
|
+
):
|
|
150
|
+
with attempt:
|
|
151
|
+
return await func(*args, **kwargs)
|
|
152
|
+
|
|
153
|
+
@wraps(func)
|
|
154
|
+
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
|
|
155
|
+
for attempt in Retrying(
|
|
156
|
+
stop=stop_after_attempt(max_attempts),
|
|
157
|
+
wait=wait_exponential(multiplier=multiplier, min=min_wait, max=max_wait),
|
|
158
|
+
retry=retry_if_exception_type(DOCKER_RETRYABLE_EXCEPTIONS),
|
|
159
|
+
before_sleep=_log_docker_retry,
|
|
160
|
+
reraise=reraise,
|
|
161
|
+
):
|
|
162
|
+
with attempt:
|
|
163
|
+
return func(*args, **kwargs)
|
|
164
|
+
|
|
165
|
+
if inspect.iscoroutinefunction(func):
|
|
166
|
+
return async_wrapper
|
|
167
|
+
return sync_wrapper
|
|
168
|
+
|
|
169
|
+
return decorator
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def retry_database(
|
|
173
|
+
max_attempts: int = 3,
|
|
174
|
+
min_wait: float = 0.5,
|
|
175
|
+
max_wait: float = 5.0,
|
|
176
|
+
multiplier: float = 2.0,
|
|
177
|
+
reraise: bool = True,
|
|
178
|
+
) -> Callable[..., Any]:
|
|
179
|
+
"""
|
|
180
|
+
Decorator for retrying database operations with exponential backoff.
|
|
181
|
+
|
|
182
|
+
Handles SQLite operational errors, lock contention, and I/O issues.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
max_attempts: Maximum number of retry attempts (default: 3)
|
|
186
|
+
min_wait: Minimum wait time between retries in seconds (default: 0.5)
|
|
187
|
+
max_wait: Maximum wait time between retries in seconds (default: 5.0)
|
|
188
|
+
multiplier: Multiplier for exponential backoff (default: 2.0)
|
|
189
|
+
reraise: Whether to reraise the last exception (default: True)
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Decorated function with retry logic
|
|
193
|
+
|
|
194
|
+
Example:
|
|
195
|
+
@retry_database(max_attempts=5)
|
|
196
|
+
async def execute_query(sql: str) -> list:
|
|
197
|
+
...
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
@overload
|
|
201
|
+
def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: ...
|
|
202
|
+
@overload
|
|
203
|
+
def decorator(func: Callable[P, R]) -> Callable[P, R]: ...
|
|
204
|
+
def decorator(func: Callable[P, Any]) -> Callable[P, Any]:
|
|
205
|
+
@wraps(func)
|
|
206
|
+
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
|
|
207
|
+
async for attempt in AsyncRetrying(
|
|
208
|
+
stop=stop_after_attempt(max_attempts),
|
|
209
|
+
wait=wait_exponential(multiplier=multiplier, min=min_wait, max=max_wait),
|
|
210
|
+
retry=retry_if_exception_type(DATABASE_RETRYABLE_EXCEPTIONS),
|
|
211
|
+
before_sleep=_log_database_retry,
|
|
212
|
+
reraise=reraise,
|
|
213
|
+
):
|
|
214
|
+
with attempt:
|
|
215
|
+
return await func(*args, **kwargs)
|
|
216
|
+
|
|
217
|
+
@wraps(func)
|
|
218
|
+
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
|
|
219
|
+
for attempt in Retrying(
|
|
220
|
+
stop=stop_after_attempt(max_attempts),
|
|
221
|
+
wait=wait_exponential(multiplier=multiplier, min=min_wait, max=max_wait),
|
|
222
|
+
retry=retry_if_exception_type(DATABASE_RETRYABLE_EXCEPTIONS),
|
|
223
|
+
before_sleep=_log_database_retry,
|
|
224
|
+
reraise=reraise,
|
|
225
|
+
):
|
|
226
|
+
with attempt:
|
|
227
|
+
return func(*args, **kwargs)
|
|
228
|
+
|
|
229
|
+
if inspect.iscoroutinefunction(func):
|
|
230
|
+
return async_wrapper
|
|
231
|
+
return sync_wrapper
|
|
232
|
+
|
|
233
|
+
return decorator
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def retry_network(
|
|
237
|
+
max_attempts: int = 3,
|
|
238
|
+
min_wait: float = 1.0,
|
|
239
|
+
max_wait: float = 30.0,
|
|
240
|
+
multiplier: float = 2.0,
|
|
241
|
+
reraise: bool = True,
|
|
242
|
+
additional_exceptions: Sequence[type[Exception]] | None = None,
|
|
243
|
+
) -> Callable[..., Any]:
|
|
244
|
+
"""
|
|
245
|
+
Decorator for retrying HTTP/network calls with exponential backoff.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
max_attempts: Maximum number of retry attempts (default: 3)
|
|
249
|
+
min_wait: Minimum wait time between retries in seconds (default: 1.0)
|
|
250
|
+
max_wait: Maximum wait time between retries in seconds (default: 30.0)
|
|
251
|
+
multiplier: Multiplier for exponential backoff (default: 2.0)
|
|
252
|
+
reraise: Whether to reraise the last exception (default: True)
|
|
253
|
+
additional_exceptions: Additional exception types to retry on
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
Decorated function with retry logic
|
|
257
|
+
|
|
258
|
+
Example:
|
|
259
|
+
@retry_network(max_attempts=5, additional_exceptions=[RequestException])
|
|
260
|
+
async def fetch_data(url: str) -> dict:
|
|
261
|
+
...
|
|
262
|
+
"""
|
|
263
|
+
exceptions_to_retry = NETWORK_RETRYABLE_EXCEPTIONS
|
|
264
|
+
if additional_exceptions:
|
|
265
|
+
exceptions_to_retry = tuple(list(exceptions_to_retry) + list(additional_exceptions))
|
|
266
|
+
|
|
267
|
+
@overload
|
|
268
|
+
def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: ...
|
|
269
|
+
@overload
|
|
270
|
+
def decorator(func: Callable[P, R]) -> Callable[P, R]: ...
|
|
271
|
+
def decorator(func: Callable[P, Any]) -> Callable[P, Any]:
|
|
272
|
+
@wraps(func)
|
|
273
|
+
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
|
|
274
|
+
async for attempt in AsyncRetrying(
|
|
275
|
+
stop=stop_after_attempt(max_attempts),
|
|
276
|
+
wait=wait_exponential(multiplier=multiplier, min=min_wait, max=max_wait),
|
|
277
|
+
retry=retry_if_exception_type(exceptions_to_retry),
|
|
278
|
+
before_sleep=_log_network_retry,
|
|
279
|
+
reraise=reraise,
|
|
280
|
+
):
|
|
281
|
+
with attempt:
|
|
282
|
+
return await func(*args, **kwargs)
|
|
283
|
+
|
|
284
|
+
@wraps(func)
|
|
285
|
+
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
|
|
286
|
+
for attempt in Retrying(
|
|
287
|
+
stop=stop_after_attempt(max_attempts),
|
|
288
|
+
wait=wait_exponential(multiplier=multiplier, min=min_wait, max=max_wait),
|
|
289
|
+
retry=retry_if_exception_type(exceptions_to_retry),
|
|
290
|
+
before_sleep=_log_network_retry,
|
|
291
|
+
reraise=reraise,
|
|
292
|
+
):
|
|
293
|
+
with attempt:
|
|
294
|
+
return func(*args, **kwargs)
|
|
295
|
+
|
|
296
|
+
if inspect.iscoroutinefunction(func):
|
|
297
|
+
return async_wrapper
|
|
298
|
+
return sync_wrapper
|
|
299
|
+
|
|
300
|
+
return decorator
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def retry_with_config(
|
|
304
|
+
max_attempts: int = 3,
|
|
305
|
+
wait_strategy: str = "exponential",
|
|
306
|
+
min_wait: float = 1.0,
|
|
307
|
+
max_wait: float = 60.0,
|
|
308
|
+
multiplier: float = 2.0,
|
|
309
|
+
fixed_wait: float = 1.0,
|
|
310
|
+
exceptions: Sequence[type[Exception]] | None = None,
|
|
311
|
+
operation_name: str = "operation",
|
|
312
|
+
reraise: bool = True,
|
|
313
|
+
) -> Callable[..., Any]:
|
|
314
|
+
"""
|
|
315
|
+
Generic retry decorator with configurable strategy.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
max_attempts: Maximum number of retry attempts
|
|
319
|
+
wait_strategy: "exponential" or "fixed"
|
|
320
|
+
min_wait: Minimum wait for exponential backoff
|
|
321
|
+
max_wait: Maximum wait time
|
|
322
|
+
multiplier: Multiplier for exponential backoff
|
|
323
|
+
fixed_wait: Wait time for fixed strategy
|
|
324
|
+
exceptions: Tuple of exception types to retry on
|
|
325
|
+
operation_name: Name for logging purposes
|
|
326
|
+
reraise: Whether to reraise the last exception
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
Decorated function with retry logic
|
|
330
|
+
"""
|
|
331
|
+
if exceptions is None:
|
|
332
|
+
exceptions = (Exception,)
|
|
333
|
+
exceptions_tuple = tuple(exceptions)
|
|
334
|
+
|
|
335
|
+
if wait_strategy == "fixed":
|
|
336
|
+
wait_fn = wait_fixed(fixed_wait)
|
|
337
|
+
else:
|
|
338
|
+
wait_fn = wait_exponential(multiplier=multiplier, min=min_wait, max=max_wait)
|
|
339
|
+
|
|
340
|
+
def log_retry(retry_state: Any) -> None:
|
|
341
|
+
_log_retry_attempt(retry_state, operation_name)
|
|
342
|
+
|
|
343
|
+
@overload
|
|
344
|
+
def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: ...
|
|
345
|
+
@overload
|
|
346
|
+
def decorator(func: Callable[P, R]) -> Callable[P, R]: ...
|
|
347
|
+
def decorator(func: Callable[P, Any]) -> Callable[P, Any]:
|
|
348
|
+
@wraps(func)
|
|
349
|
+
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
|
|
350
|
+
async for attempt in AsyncRetrying(
|
|
351
|
+
stop=stop_after_attempt(max_attempts),
|
|
352
|
+
wait=wait_fn,
|
|
353
|
+
retry=retry_if_exception_type(exceptions_tuple),
|
|
354
|
+
before_sleep=log_retry,
|
|
355
|
+
reraise=reraise,
|
|
356
|
+
):
|
|
357
|
+
with attempt:
|
|
358
|
+
return await func(*args, **kwargs)
|
|
359
|
+
|
|
360
|
+
@wraps(func)
|
|
361
|
+
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
|
|
362
|
+
for attempt in Retrying(
|
|
363
|
+
stop=stop_after_attempt(max_attempts),
|
|
364
|
+
wait=wait_fn,
|
|
365
|
+
retry=retry_if_exception_type(exceptions_tuple),
|
|
366
|
+
before_sleep=log_retry,
|
|
367
|
+
reraise=reraise,
|
|
368
|
+
):
|
|
369
|
+
with attempt:
|
|
370
|
+
return func(*args, **kwargs)
|
|
371
|
+
|
|
372
|
+
if inspect.iscoroutinefunction(func):
|
|
373
|
+
return async_wrapper
|
|
374
|
+
return sync_wrapper
|
|
375
|
+
|
|
376
|
+
return decorator
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# =============================================================================
|
|
380
|
+
# Legacy Support - RetryConfig and RetryHelper classes
|
|
381
|
+
# =============================================================================
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class RetryConfig:
|
|
385
|
+
"""Configuration for retry behavior (legacy support)."""
|
|
386
|
+
|
|
387
|
+
def __init__(
|
|
388
|
+
self,
|
|
389
|
+
max_attempts: int = 3,
|
|
390
|
+
initial_delay: float = 1.0,
|
|
391
|
+
backoff_factor: float = 2.0,
|
|
392
|
+
max_delay: float = 60.0,
|
|
393
|
+
retryable_exceptions: Sequence[type[Exception]] = (Exception,),
|
|
394
|
+
retry_on_result: Callable[[Any], bool] | None = None,
|
|
395
|
+
):
|
|
396
|
+
"""
|
|
397
|
+
Initialize retry configuration.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
max_attempts: Maximum number of attempts (including initial)
|
|
401
|
+
initial_delay: Initial delay between retries in seconds
|
|
402
|
+
backoff_factor: Multiplier for delay after each attempt
|
|
403
|
+
max_delay: Maximum delay between retries
|
|
404
|
+
retryable_exceptions: Tuple of exception types to retry on
|
|
405
|
+
retry_on_result: Optional function to check if result should trigger retry
|
|
406
|
+
"""
|
|
407
|
+
self.max_attempts = max_attempts
|
|
408
|
+
self.initial_delay = initial_delay
|
|
409
|
+
self.backoff_factor = backoff_factor
|
|
410
|
+
self.max_delay = max_delay
|
|
411
|
+
self.retryable_exceptions = tuple(retryable_exceptions)
|
|
412
|
+
self.retry_on_result = retry_on_result
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class RetryHelper:
|
|
416
|
+
"""Helper class for retry operations with exponential backoff (legacy support)."""
|
|
417
|
+
|
|
418
|
+
@staticmethod
|
|
419
|
+
def calculate_delay(
|
|
420
|
+
attempt: int,
|
|
421
|
+
initial_delay: float = 1.0,
|
|
422
|
+
backoff_factor: float = 2.0,
|
|
423
|
+
max_delay: float = 60.0,
|
|
424
|
+
) -> float:
|
|
425
|
+
"""
|
|
426
|
+
Calculate delay for a given attempt using exponential backoff.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
attempt: Current attempt number (0-indexed)
|
|
430
|
+
initial_delay: Initial delay in seconds
|
|
431
|
+
backoff_factor: Multiplier for each subsequent attempt
|
|
432
|
+
max_delay: Maximum delay cap
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
Delay in seconds for the current attempt
|
|
436
|
+
"""
|
|
437
|
+
delay = initial_delay * (backoff_factor**attempt)
|
|
438
|
+
return min(delay, max_delay)
|
|
439
|
+
|
|
440
|
+
@staticmethod
|
|
441
|
+
async def retry_async(
|
|
442
|
+
func: Callable[..., Any],
|
|
443
|
+
*args: Any,
|
|
444
|
+
config: RetryConfig | None = None,
|
|
445
|
+
**kwargs: Any,
|
|
446
|
+
) -> Any:
|
|
447
|
+
"""
|
|
448
|
+
Execute an async function with retry logic.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
func: Async function to execute
|
|
452
|
+
*args: Positional arguments to pass to func
|
|
453
|
+
config: Retry configuration (uses defaults if None)
|
|
454
|
+
**kwargs: Keyword arguments to pass to func
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
Result from the function
|
|
458
|
+
|
|
459
|
+
Raises:
|
|
460
|
+
Last exception if all retries exhausted
|
|
461
|
+
"""
|
|
462
|
+
if config is None:
|
|
463
|
+
config = RetryConfig()
|
|
464
|
+
|
|
465
|
+
last_exception: BaseException | None = None
|
|
466
|
+
|
|
467
|
+
for attempt in range(config.max_attempts):
|
|
468
|
+
try:
|
|
469
|
+
result = await func(*args, **kwargs)
|
|
470
|
+
|
|
471
|
+
# Check if result should trigger retry
|
|
472
|
+
if config.retry_on_result and config.retry_on_result(result):
|
|
473
|
+
if attempt < config.max_attempts - 1:
|
|
474
|
+
delay = RetryHelper.calculate_delay(
|
|
475
|
+
attempt,
|
|
476
|
+
config.initial_delay,
|
|
477
|
+
config.backoff_factor,
|
|
478
|
+
config.max_delay,
|
|
479
|
+
)
|
|
480
|
+
logger.debug(
|
|
481
|
+
f"Retry triggered by result check, attempt {attempt + 1}/{config.max_attempts}, "
|
|
482
|
+
f"waiting {delay:.2f}s"
|
|
483
|
+
)
|
|
484
|
+
await asyncio.sleep(delay)
|
|
485
|
+
continue
|
|
486
|
+
|
|
487
|
+
return result
|
|
488
|
+
|
|
489
|
+
except config.retryable_exceptions as e:
|
|
490
|
+
last_exception = e
|
|
491
|
+
if attempt < config.max_attempts - 1:
|
|
492
|
+
delay = RetryHelper.calculate_delay(
|
|
493
|
+
attempt,
|
|
494
|
+
config.initial_delay,
|
|
495
|
+
config.backoff_factor,
|
|
496
|
+
config.max_delay,
|
|
497
|
+
)
|
|
498
|
+
logger.warning(
|
|
499
|
+
f"Attempt {attempt + 1}/{config.max_attempts} failed: {e}. Retrying in {delay:.2f}s"
|
|
500
|
+
)
|
|
501
|
+
await asyncio.sleep(delay)
|
|
502
|
+
else:
|
|
503
|
+
logger.error(f"All {config.max_attempts} attempts failed. Last error: {e}")
|
|
504
|
+
|
|
505
|
+
if last_exception:
|
|
506
|
+
raise last_exception
|
|
507
|
+
raise RuntimeError("Retry exhausted without result or exception")
|
|
508
|
+
|
|
509
|
+
@staticmethod
|
|
510
|
+
def retry_sync(
|
|
511
|
+
func: Callable[..., T],
|
|
512
|
+
*args: Any,
|
|
513
|
+
config: RetryConfig | None = None,
|
|
514
|
+
**kwargs: Any,
|
|
515
|
+
) -> T:
|
|
516
|
+
"""
|
|
517
|
+
Execute a sync function with retry logic.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
func: Sync function to execute
|
|
521
|
+
*args: Positional arguments to pass to func
|
|
522
|
+
config: Retry configuration (uses defaults if None)
|
|
523
|
+
**kwargs: Keyword arguments to pass to func
|
|
524
|
+
|
|
525
|
+
Returns:
|
|
526
|
+
Result from the function
|
|
527
|
+
|
|
528
|
+
Raises:
|
|
529
|
+
Last exception if all retries exhausted
|
|
530
|
+
"""
|
|
531
|
+
import time
|
|
532
|
+
|
|
533
|
+
if config is None:
|
|
534
|
+
config = RetryConfig()
|
|
535
|
+
|
|
536
|
+
last_exception: BaseException | None = None
|
|
537
|
+
|
|
538
|
+
for attempt in range(config.max_attempts):
|
|
539
|
+
try:
|
|
540
|
+
result = func(*args, **kwargs)
|
|
541
|
+
|
|
542
|
+
# Check if result should trigger retry
|
|
543
|
+
if config.retry_on_result and config.retry_on_result(result):
|
|
544
|
+
if attempt < config.max_attempts - 1:
|
|
545
|
+
delay = RetryHelper.calculate_delay(
|
|
546
|
+
attempt,
|
|
547
|
+
config.initial_delay,
|
|
548
|
+
config.backoff_factor,
|
|
549
|
+
config.max_delay,
|
|
550
|
+
)
|
|
551
|
+
logger.debug(
|
|
552
|
+
f"Retry triggered by result check, attempt {attempt + 1}/{config.max_attempts}, "
|
|
553
|
+
f"waiting {delay:.2f}s"
|
|
554
|
+
)
|
|
555
|
+
time.sleep(delay)
|
|
556
|
+
continue
|
|
557
|
+
|
|
558
|
+
return result
|
|
559
|
+
|
|
560
|
+
except config.retryable_exceptions as e:
|
|
561
|
+
last_exception = e
|
|
562
|
+
if attempt < config.max_attempts - 1:
|
|
563
|
+
delay = RetryHelper.calculate_delay(
|
|
564
|
+
attempt,
|
|
565
|
+
config.initial_delay,
|
|
566
|
+
config.backoff_factor,
|
|
567
|
+
config.max_delay,
|
|
568
|
+
)
|
|
569
|
+
logger.warning(
|
|
570
|
+
f"Attempt {attempt + 1}/{config.max_attempts} failed: {e}. Retrying in {delay:.2f}s"
|
|
571
|
+
)
|
|
572
|
+
time.sleep(delay)
|
|
573
|
+
else:
|
|
574
|
+
logger.error(f"All {config.max_attempts} attempts failed. Last error: {e}")
|
|
575
|
+
|
|
576
|
+
if last_exception:
|
|
577
|
+
raise last_exception
|
|
578
|
+
raise RuntimeError("Retry exhausted without result or exception")
|
|
579
|
+
|
|
580
|
+
@staticmethod
|
|
581
|
+
async def poll_until(
|
|
582
|
+
check_func: Callable[..., Any],
|
|
583
|
+
*args: Any,
|
|
584
|
+
condition: Callable[[Any], bool] | None = None,
|
|
585
|
+
max_attempts: int = 30,
|
|
586
|
+
poll_interval: float = 2.0,
|
|
587
|
+
timeout: float | None = None,
|
|
588
|
+
description: str = "condition",
|
|
589
|
+
**kwargs: Any,
|
|
590
|
+
) -> Any:
|
|
591
|
+
"""
|
|
592
|
+
Poll a function until a condition is met or timeout occurs.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
check_func: Async function to check (can be sync, will be wrapped)
|
|
596
|
+
*args: Positional arguments for check_func
|
|
597
|
+
condition: Function to evaluate result (default: truthy check)
|
|
598
|
+
max_attempts: Maximum poll attempts
|
|
599
|
+
poll_interval: Seconds between polls
|
|
600
|
+
timeout: Optional total timeout in seconds
|
|
601
|
+
description: Description for logging
|
|
602
|
+
**kwargs: Keyword arguments for check_func
|
|
603
|
+
|
|
604
|
+
Returns:
|
|
605
|
+
Result that satisfied the condition
|
|
606
|
+
|
|
607
|
+
Raises:
|
|
608
|
+
TimeoutError: If condition not met within limits
|
|
609
|
+
"""
|
|
610
|
+
if condition is None:
|
|
611
|
+
condition = bool
|
|
612
|
+
|
|
613
|
+
start_time = asyncio.get_event_loop().time()
|
|
614
|
+
|
|
615
|
+
for attempt in range(max_attempts):
|
|
616
|
+
# Check timeout
|
|
617
|
+
if timeout is not None:
|
|
618
|
+
elapsed = asyncio.get_event_loop().time() - start_time
|
|
619
|
+
if elapsed >= timeout:
|
|
620
|
+
raise TimeoutError(f"Polling for {description} timed out after {elapsed:.1f}s")
|
|
621
|
+
|
|
622
|
+
try:
|
|
623
|
+
# Handle both sync and async functions
|
|
624
|
+
if inspect.iscoroutinefunction(check_func):
|
|
625
|
+
result = await check_func(*args, **kwargs)
|
|
626
|
+
else:
|
|
627
|
+
result = check_func(*args, **kwargs)
|
|
628
|
+
|
|
629
|
+
if condition(result):
|
|
630
|
+
logger.debug(f"Condition '{description}' met after {attempt + 1} attempts")
|
|
631
|
+
return result
|
|
632
|
+
|
|
633
|
+
except (
|
|
634
|
+
Exception
|
|
635
|
+
) as e: # Intentionally broad: check_func is an arbitrary caller-supplied callback
|
|
636
|
+
logger.debug(
|
|
637
|
+
f"Poll attempt {attempt + 1}/{max_attempts} for '{description}' raised: {e}"
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
if attempt < max_attempts - 1:
|
|
641
|
+
await asyncio.sleep(poll_interval)
|
|
642
|
+
|
|
643
|
+
raise TimeoutError(f"Condition '{description}' not met after {max_attempts} attempts")
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def with_retry(
|
|
647
|
+
config: RetryConfig | None = None,
|
|
648
|
+
max_attempts: int = 3,
|
|
649
|
+
initial_delay: float = 1.0,
|
|
650
|
+
backoff_factor: float = 2.0,
|
|
651
|
+
retryable_exceptions: Sequence[type[Exception]] = (Exception,),
|
|
652
|
+
) -> Callable[..., Any]:
|
|
653
|
+
"""
|
|
654
|
+
Legacy decorator for adding retry logic to async functions.
|
|
655
|
+
|
|
656
|
+
Args:
|
|
657
|
+
config: RetryConfig instance (overrides other params if provided)
|
|
658
|
+
max_attempts: Maximum attempts if config not provided
|
|
659
|
+
initial_delay: Initial delay if config not provided
|
|
660
|
+
backoff_factor: Backoff factor if config not provided
|
|
661
|
+
retryable_exceptions: Exceptions to retry on if config not provided
|
|
662
|
+
|
|
663
|
+
Returns:
|
|
664
|
+
Decorated function with retry logic
|
|
665
|
+
"""
|
|
666
|
+
if config is None:
|
|
667
|
+
config = RetryConfig(
|
|
668
|
+
max_attempts=max_attempts,
|
|
669
|
+
initial_delay=initial_delay,
|
|
670
|
+
backoff_factor=backoff_factor,
|
|
671
|
+
retryable_exceptions=retryable_exceptions,
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
@overload
|
|
675
|
+
def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: ...
|
|
676
|
+
@overload
|
|
677
|
+
def decorator(func: Callable[P, R]) -> Callable[P, R]: ...
|
|
678
|
+
def decorator(func: Callable[P, Any]) -> Callable[P, Any]:
|
|
679
|
+
@wraps(func)
|
|
680
|
+
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
|
|
681
|
+
return await RetryHelper.retry_async(func, *args, config=config, **kwargs)
|
|
682
|
+
|
|
683
|
+
@wraps(func)
|
|
684
|
+
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
|
|
685
|
+
return RetryHelper.retry_sync(func, *args, config=config, **kwargs)
|
|
686
|
+
|
|
687
|
+
if inspect.iscoroutinefunction(func):
|
|
688
|
+
return async_wrapper
|
|
689
|
+
return sync_wrapper
|
|
690
|
+
|
|
691
|
+
return decorator
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
# =============================================================================
|
|
695
|
+
# Exports
|
|
696
|
+
# =============================================================================
|
|
697
|
+
|
|
698
|
+
__all__ = [
|
|
699
|
+
# Primary decorators (recommended)
|
|
700
|
+
"retry_docker",
|
|
701
|
+
"retry_database",
|
|
702
|
+
"retry_network",
|
|
703
|
+
"retry_with_config",
|
|
704
|
+
# Exception categories
|
|
705
|
+
"DOCKER_RETRYABLE_EXCEPTIONS",
|
|
706
|
+
"DATABASE_RETRYABLE_EXCEPTIONS",
|
|
707
|
+
"NETWORK_RETRYABLE_EXCEPTIONS",
|
|
708
|
+
# Legacy support
|
|
709
|
+
"RetryConfig",
|
|
710
|
+
"RetryHelper",
|
|
711
|
+
"with_retry",
|
|
712
|
+
]
|