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.
Files changed (109) hide show
  1. coding_client/__init__.py +1 -0
  2. coding_client/api/__init__.py +19 -0
  3. coding_client/api/exception_handlers.py +158 -0
  4. coding_client/api/logging_setup.py +44 -0
  5. coding_client/api/metrics.py +40 -0
  6. coding_client/api/openapi_config.py +124 -0
  7. coding_client/api/request_logging.py +60 -0
  8. coding_client/api/validation.py +150 -0
  9. coding_client/auth/__init__.py +78 -0
  10. coding_client/auth/_default_tenant.py +132 -0
  11. coding_client/auth/_header_trust.py +98 -0
  12. coding_client/auth/_onbehalf.py +315 -0
  13. coding_client/auth/_trusted_networks.py +94 -0
  14. coding_client/auth/dependencies.py +61 -0
  15. coding_client/auth/middleware.py +445 -0
  16. coding_client/auth/models.py +83 -0
  17. coding_client/auth/outbound.py +137 -0
  18. coding_client/auth/rbac.py +21 -0
  19. coding_client/auth/rbac_coding.py +5 -0
  20. coding_client/auth/tenant.py +650 -0
  21. coding_client/auth/websocket.py +148 -0
  22. coding_client/clients/__init__.py +24 -0
  23. coding_client/clients/base.py +131 -0
  24. coding_client/clients/coding_ai.py +33 -0
  25. coding_client/clients/coding_analysis.py +36 -0
  26. coding_client/clients/coding_containers.py +45 -0
  27. coding_client/clients/coding_github.py +127 -0
  28. coding_client/clients/coding_realtime.py +127 -0
  29. coding_client/clients/coding_security.py +51 -0
  30. coding_client/clients/coding_testing.py +31 -0
  31. coding_client/clients/coding_workspaces.py +265 -0
  32. coding_client/config/__init__.py +12 -0
  33. coding_client/config/base_settings.py +61 -0
  34. coding_client/config/yaml_config_loader.py +172 -0
  35. coding_client/db/__init__.py +42 -0
  36. coding_client/db/db_types.py +122 -0
  37. coding_client/db/exceptions.py +59 -0
  38. coding_client/db/postgres_connection.py +572 -0
  39. coding_client/db/tenant_query_helper.py +417 -0
  40. coding_client/db_init.py +49 -0
  41. coding_client/exceptions/__init__.py +67 -0
  42. coding_client/exceptions/api_exceptions.py +395 -0
  43. coding_client/exceptions/pydantic_ai_exceptions.py +533 -0
  44. coding_client/interfaces/__init__.py +22 -0
  45. coding_client/interfaces/cache_interface.py +179 -0
  46. coding_client/interfaces/container_interface.py +49 -0
  47. coding_client/interfaces/docker_interface.py +283 -0
  48. coding_client/interfaces/graph_interface.py +28 -0
  49. coding_client/interfaces/security_interface.py +72 -0
  50. coding_client/interfaces/webhook_interface.py +67 -0
  51. coding_client/llm/__init__.py +54 -0
  52. coding_client/llm/base_agent.py +195 -0
  53. coding_client/llm/context.py +121 -0
  54. coding_client/llm/responses.py +102 -0
  55. coding_client/llm/routing.py +82 -0
  56. coding_client/llm/settings.py +102 -0
  57. coding_client/models/__init__.py +62 -0
  58. coding_client/models/agent.py +38 -0
  59. coding_client/models/ai_test_failure.py +745 -0
  60. coding_client/models/analysis.py +330 -0
  61. coding_client/models/architecture.py +685 -0
  62. coding_client/models/bug.py +128 -0
  63. coding_client/models/code_graph.py +317 -0
  64. coding_client/models/container.py +241 -0
  65. coding_client/models/core.py +878 -0
  66. coding_client/models/docker_container.py +104 -0
  67. coding_client/models/github.py +293 -0
  68. coding_client/models/logging.py +29 -0
  69. coding_client/models/package_manager.py +257 -0
  70. coding_client/models/pr_validation.py +440 -0
  71. coding_client/models/pydantic_ai.py +395 -0
  72. coding_client/models/repo_scanner.py +249 -0
  73. coding_client/models/responses.py +431 -0
  74. coding_client/models/security.py +185 -0
  75. coding_client/models/static_analysis.py +508 -0
  76. coding_client/models/system.py +48 -0
  77. coding_client/models/test_executor.py +204 -0
  78. coding_client/models/testing.py +673 -0
  79. coding_client/models/testing_config.py +200 -0
  80. coding_client/models/validation.py +127 -0
  81. coding_client/models_init.py +272 -0
  82. coding_client/telemetry.py +195 -0
  83. coding_client/testing.py +180 -0
  84. coding_client/types/__init__.py +130 -0
  85. coding_client/types/constraint_types.py +276 -0
  86. coding_client/types/core_types.py +112 -0
  87. coding_client/types/state_machine.py +443 -0
  88. coding_client/utils/__init__.py +86 -0
  89. coding_client/utils/archive_utils.py +387 -0
  90. coding_client/utils/ast_debug.py +45 -0
  91. coding_client/utils/async_timeout.py +364 -0
  92. coding_client/utils/constants.py +518 -0
  93. coding_client/utils/content_processing.py +326 -0
  94. coding_client/utils/database.py +916 -0
  95. coding_client/utils/encoding_helper.py +222 -0
  96. coding_client/utils/enum_helpers.py +166 -0
  97. coding_client/utils/file_operations.py +177 -0
  98. coding_client/utils/filesystem_ops.py +528 -0
  99. coding_client/utils/http_client.py +562 -0
  100. coding_client/utils/path_utils.py +471 -0
  101. coding_client/utils/profiling.py +421 -0
  102. coding_client/utils/response_builder.py +240 -0
  103. coding_client/utils/retry_helper.py +712 -0
  104. coding_client/utils/security_validation.py +423 -0
  105. coding_client-1.21.2.dist-info/METADATA +234 -0
  106. coding_client-1.21.2.dist-info/RECORD +109 -0
  107. coding_client-1.21.2.dist-info/WHEEL +5 -0
  108. coding_client-1.21.2.dist-info/licenses/LICENSE +201 -0
  109. 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
+ ]