pal-found-cli 0.1.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.
- pal_found_cli/__init__.py +1 -0
- pal_found_cli/_version.py +24 -0
- pal_found_cli/admin/__init__.py +2 -0
- pal_found_cli/admin/scripts/__init__.py +2 -0
- pal_found_cli/admin/scripts/pal_found_admin_cli.py +600 -0
- pal_found_cli/aip_agents/__init__.py +1 -0
- pal_found_cli/aip_agents/metadata-allow-list.md +20 -0
- pal_found_cli/aip_agents/scripts/__init__.py +1 -0
- pal_found_cli/aip_agents/scripts/pal_found_aip_agents_cli.py +704 -0
- pal_found_cli/audit/__init__.py +1 -0
- pal_found_cli/audit/metadata-allow-list.md +4 -0
- pal_found_cli/audit/scripts/__init__.py +1 -0
- pal_found_cli/audit/scripts/pal_found_audit_cli.py +387 -0
- pal_found_cli/checkpoints/__init__.py +9 -0
- pal_found_cli/checkpoints/metadata-allow-list.md +7 -0
- pal_found_cli/checkpoints/scripts/__init__.py +1 -0
- pal_found_cli/checkpoints/scripts/pal_found_checkpoints_cli.py +527 -0
- pal_found_cli/common/__init__.py +56 -0
- pal_found_cli/common/access_control_guard.py +591 -0
- pal_found_cli/common/async_client_factory.py +161 -0
- pal_found_cli/common/auth_provider.py +82 -0
- pal_found_cli/common/binary_download_handler.py +288 -0
- pal_found_cli/common/config_loader.py +388 -0
- pal_found_cli/common/error_serializer.py +412 -0
- pal_found_cli/common/log_setup.py +241 -0
- pal_found_cli/common/metadata-allow-list.md +169 -0
- pal_found_cli/common/output_formatter.py +267 -0
- pal_found_cli/common/pagination_helper.py +212 -0
- pal_found_cli/common/retry.py +617 -0
- pal_found_cli/common/sdk_error_utils.py +112 -0
- pal_found_cli/common/session_manager.py +461 -0
- pal_found_cli/common/tracing_provider.py +107 -0
- pal_found_cli/connectivity/__init__.py +9 -0
- pal_found_cli/connectivity/metadata-allow-list.md +24 -0
- pal_found_cli/connectivity/scripts/__init__.py +1 -0
- pal_found_cli/connectivity/scripts/pal_found_connectivity_cli.py +720 -0
- pal_found_cli/data_health/__init__.py +9 -0
- pal_found_cli/data_health/metadata-allow-list.md +10 -0
- pal_found_cli/data_health/scripts/__init__.py +1 -0
- pal_found_cli/data_health/scripts/pal_found_data_health_cli.py +458 -0
- pal_found_cli/datasets/__init__.py +1 -0
- pal_found_cli/datasets/scripts/__init__.py +1 -0
- pal_found_cli/datasets/scripts/pal_found_datasets_cli.py +732 -0
- pal_found_cli/filesystem/__init__.py +2 -0
- pal_found_cli/filesystem/scripts/__init__.py +2 -0
- pal_found_cli/filesystem/scripts/pal_found_filesystem_cli.py +659 -0
- pal_found_cli/functions/__init__.py +2 -0
- pal_found_cli/functions/scripts/__init__.py +2 -0
- pal_found_cli/functions/scripts/pal_found_functions_cli.py +389 -0
- pal_found_cli/language_models/__init__.py +1 -0
- pal_found_cli/language_models/metadata-allow-list.md +6 -0
- pal_found_cli/language_models/scripts/__init__.py +1 -0
- pal_found_cli/language_models/scripts/pal_found_language_models_cli.py +342 -0
- pal_found_cli/media_sets/__init__.py +9 -0
- pal_found_cli/media_sets/metadata-allow-list.md +23 -0
- pal_found_cli/media_sets/scripts/__init__.py +1 -0
- pal_found_cli/media_sets/scripts/pal_found_media_sets_cli.py +642 -0
- pal_found_cli/models/__init__.py +5 -0
- pal_found_cli/models/metadata-allow-list.md +27 -0
- pal_found_cli/models/scripts/__init__.py +1 -0
- pal_found_cli/models/scripts/pal_found_models_cli.py +714 -0
- pal_found_cli/ontologies/__init__.py +1 -0
- pal_found_cli/ontologies/scripts/__init__.py +1 -0
- pal_found_cli/ontologies/scripts/pal_found_ontologies_cli.py +1225 -0
- pal_found_cli/orchestration/__init__.py +9 -0
- pal_found_cli/orchestration/metadata-allow-list.md +24 -0
- pal_found_cli/orchestration/scripts/__init__.py +1 -0
- pal_found_cli/orchestration/scripts/pal_found_orchestration_cli.py +640 -0
- pal_found_cli/sql_queries/__init__.py +9 -0
- pal_found_cli/sql_queries/metadata-allow-list.md +9 -0
- pal_found_cli/sql_queries/scripts/__init__.py +1 -0
- pal_found_cli/sql_queries/scripts/pal_found_sql_queries_cli.py +514 -0
- pal_found_cli/streams/__init__.py +9 -0
- pal_found_cli/streams/metadata-allow-list.md +19 -0
- pal_found_cli/streams/scripts/__init__.py +1 -0
- pal_found_cli/streams/scripts/pal_found_streams_cli.py +615 -0
- pal_found_cli/third_party_applications/__init__.py +9 -0
- pal_found_cli/third_party_applications/metadata-allow-list.md +13 -0
- pal_found_cli/third_party_applications/scripts/__init__.py +1 -0
- pal_found_cli/third_party_applications/scripts/pal_found_third_party_applications_cli.py +547 -0
- pal_found_cli/widgets/__init__.py +9 -0
- pal_found_cli/widgets/metadata-allow-list.md +12 -0
- pal_found_cli/widgets/scripts/__init__.py +1 -0
- pal_found_cli/widgets/scripts/pal_found_widgets_cli.py +571 -0
- pal_found_cli-0.1.2.dist-info/METADATA +74 -0
- pal_found_cli-0.1.2.dist-info/RECORD +89 -0
- pal_found_cli-0.1.2.dist-info/WHEEL +5 -0
- pal_found_cli-0.1.2.dist-info/entry_points.txt +19 -0
- pal_found_cli-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""RetryHandler with exponential backoff and jitter (ADR-002, FR-ERR-3/4).
|
|
3
|
+
|
|
4
|
+
Provides a configurable retry mechanism for async operations with
|
|
5
|
+
exponential backoff, optional jitter, and support for both decorator
|
|
6
|
+
and context manager protocols. Per-call timeouts are enforced via
|
|
7
|
+
``asyncio.wait_for()`` and SIGINT/SIGTERM cancellation is wired through
|
|
8
|
+
an optional signal handler (ADR-002 §Consequences).
|
|
9
|
+
|
|
10
|
+
Environment Variables (canonical, per SRS Table 5.3 / FR-ERR-4)
|
|
11
|
+
---------------------------------------------------------------
|
|
12
|
+
FOUNDRY_AGENTIC_CLI_RETRY_MAX_ATTEMPTS : int
|
|
13
|
+
Maximum number of retry attempts. Default: 4
|
|
14
|
+
FOUNDRY_AGENTIC_CLI_RETRY_INITIAL_DELAY_MS : float
|
|
15
|
+
Base delay in **milliseconds** for exponential backoff. Default: 500
|
|
16
|
+
FOUNDRY_AGENTIC_CLI_RETRY_MAX_DELAY_MS : float
|
|
17
|
+
Maximum delay cap in **milliseconds**. Default: 30000
|
|
18
|
+
FOUNDRY_AGENTIC_CLI_RETRY_MULTIPLIER : float
|
|
19
|
+
Backoff multiplier applied to the previous delay. Default: 2.0
|
|
20
|
+
FOUNDRY_AGENTIC_CLI_RETRY_JITTER : bool
|
|
21
|
+
Enable ±10% random jitter on delays. Default: true
|
|
22
|
+
|
|
23
|
+
Internal arithmetic keeps delays in seconds for ``asyncio.sleep()``; env
|
|
24
|
+
vars are read in milliseconds and converted at the boundary so the
|
|
25
|
+
canonical defaults match SRS Table 5.3.
|
|
26
|
+
|
|
27
|
+
Backoff Formula
|
|
28
|
+
---------------
|
|
29
|
+
delay_seconds = min(initial_ms * (multiplier ** attempt), max_ms) / 1000
|
|
30
|
+
With jitter: delay_seconds *= (1 + random.uniform(-0.1, 0.1))
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
import logging
|
|
35
|
+
import os
|
|
36
|
+
import random
|
|
37
|
+
import signal
|
|
38
|
+
from collections.abc import Callable
|
|
39
|
+
from contextlib import asynccontextmanager
|
|
40
|
+
from functools import wraps
|
|
41
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
42
|
+
|
|
43
|
+
import requests
|
|
44
|
+
|
|
45
|
+
from pal_found_cli.common.sdk_error_utils import (
|
|
46
|
+
is_sdk_retryable_transport,
|
|
47
|
+
sdk_http_status,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
from pal_found_cli.common.tracing_provider import B3Context, TracingProvider
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
56
|
+
|
|
57
|
+
# Canonical environment variable names (FR-ERR-4, SRS Table 5.3).
|
|
58
|
+
# Renamed from the legacy FOUNDRY_* prefix per architect decision on
|
|
59
|
+
# QUESTION-010 (2026-07-04): the canonical FOUNDRY_AGENTIC_CLI_*
|
|
60
|
+
# namespace is authoritative across all retry configuration.
|
|
61
|
+
ENV_MAX_ATTEMPTS = "FOUNDRY_AGENTIC_CLI_RETRY_MAX_ATTEMPTS"
|
|
62
|
+
ENV_INITIAL_DELAY_MS = "FOUNDRY_AGENTIC_CLI_RETRY_INITIAL_DELAY_MS"
|
|
63
|
+
ENV_MAX_DELAY_MS = "FOUNDRY_AGENTIC_CLI_RETRY_MAX_DELAY_MS"
|
|
64
|
+
ENV_MULTIPLIER = "FOUNDRY_AGENTIC_CLI_RETRY_MULTIPLIER"
|
|
65
|
+
ENV_JITTER = "FOUNDRY_AGENTIC_CLI_RETRY_JITTER"
|
|
66
|
+
|
|
67
|
+
# Default values (milliseconds for delay vars, per SRS Table 5.3).
|
|
68
|
+
DEFAULT_MAX_ATTEMPTS = 4
|
|
69
|
+
DEFAULT_INITIAL_DELAY_MS = 500.0
|
|
70
|
+
DEFAULT_MAX_DELAY_MS = 30000.0
|
|
71
|
+
DEFAULT_MULTIPLIER = 2.0
|
|
72
|
+
DEFAULT_JITTER = True
|
|
73
|
+
|
|
74
|
+
# Per-call timeout (seconds). Read from the canonical env var so a single
|
|
75
|
+
# override lifts both RetryHandler default and any explicit wait_for site.
|
|
76
|
+
ENV_TIMEOUT_S = "FOUNDRY_AGENTIC_CLI_TIMEOUT_S"
|
|
77
|
+
DEFAULT_TIMEOUT_S = 30.0
|
|
78
|
+
|
|
79
|
+
# HTTP status codes that MUST trigger retry per FR-ERR-3.
|
|
80
|
+
RETRYABLE_HTTP_STATUSES: set[int] = {429, 503}
|
|
81
|
+
|
|
82
|
+
# Legacy aliases retained for older tests/call sites. The canonical names above
|
|
83
|
+
# are authoritative; these constants are not read from the environment.
|
|
84
|
+
ENV_MAX_RETRIES = "FOUNDRY_MAX_RETRIES"
|
|
85
|
+
ENV_RETRY_BASE_DELAY = "FOUNDRY_RETRY_BASE_DELAY"
|
|
86
|
+
ENV_RETRY_MAX_DELAY = "FOUNDRY_RETRY_MAX_DELAY"
|
|
87
|
+
ENV_RETRY_JITTER = "FOUNDRY_RETRY_JITTER"
|
|
88
|
+
DEFAULT_MAX_RETRIES = 3
|
|
89
|
+
DEFAULT_BASE_DELAY = 1.0
|
|
90
|
+
DEFAULT_MAX_DELAY = 30.0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_bool_env(env_var: str, default: bool) -> bool:
|
|
94
|
+
"""Parse a boolean environment variable.
|
|
95
|
+
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
env_var : str
|
|
99
|
+
Environment variable name.
|
|
100
|
+
default : bool
|
|
101
|
+
Default value if not set.
|
|
102
|
+
|
|
103
|
+
Returns
|
|
104
|
+
-------
|
|
105
|
+
bool
|
|
106
|
+
Parsed boolean value.
|
|
107
|
+
"""
|
|
108
|
+
val = os.environ.get(env_var)
|
|
109
|
+
if val is None:
|
|
110
|
+
return default
|
|
111
|
+
return val.lower() in ("true", "1", "yes", "on")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _is_retryable_http_status(exception: BaseException) -> bool:
|
|
115
|
+
"""Return True if *exception* carries an HTTP 429 or 503 response.
|
|
116
|
+
|
|
117
|
+
Implements FR-ERR-3: only 429 (Too Many Requests) and 503 (Service
|
|
118
|
+
Unavailable) are retryable. Other transport errors (connection reset,
|
|
119
|
+
DNS failure, etc.) are also retryable via ``DEFAULT_RETRY_EXCEPTIONS``.
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
exception : BaseException
|
|
124
|
+
The exception to inspect.
|
|
125
|
+
|
|
126
|
+
Returns
|
|
127
|
+
-------
|
|
128
|
+
bool
|
|
129
|
+
True if the exception exposes a 429/503 status code.
|
|
130
|
+
"""
|
|
131
|
+
return sdk_http_status(exception) in RETRYABLE_HTTP_STATUSES
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _get_http_status(exception: BaseException) -> int | None:
|
|
135
|
+
"""Extract an HTTP status code from an exception response, if present."""
|
|
136
|
+
return sdk_http_status(exception)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class SignalCancellationError(TimeoutError):
|
|
140
|
+
"""Raised when SIGINT or SIGTERM cancels an active retry operation."""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _signal_name(signum: int) -> str:
|
|
144
|
+
"""Return a stable signal name for user-facing timeout errors."""
|
|
145
|
+
try:
|
|
146
|
+
return signal.Signals(signum).name
|
|
147
|
+
except ValueError:
|
|
148
|
+
return f"signal {signum}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class _SignalCancellationScope:
|
|
152
|
+
"""Scoped SIGINT/SIGTERM handlers for one active async attempt."""
|
|
153
|
+
|
|
154
|
+
def __init__(self, loop: asyncio.AbstractEventLoop | None = None) -> None:
|
|
155
|
+
self.loop = loop
|
|
156
|
+
self.signum: int | None = None
|
|
157
|
+
self._previous: dict[int, Any] = {}
|
|
158
|
+
self._installed: list[tuple[int, str]] = []
|
|
159
|
+
|
|
160
|
+
def install(self) -> Callable[[], None]:
|
|
161
|
+
"""Install handlers and return a restore callback."""
|
|
162
|
+
target_loop = self.loop or asyncio.get_event_loop()
|
|
163
|
+
if not target_loop.is_running():
|
|
164
|
+
return lambda: None
|
|
165
|
+
|
|
166
|
+
main_task = asyncio.current_task(target_loop)
|
|
167
|
+
|
|
168
|
+
def _cancel(signum: int, frame: Any = None) -> None:
|
|
169
|
+
self.signum = signum
|
|
170
|
+
if main_task is not None and not main_task.done():
|
|
171
|
+
target_loop.call_soon_threadsafe(main_task.cancel)
|
|
172
|
+
|
|
173
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
174
|
+
try:
|
|
175
|
+
target_loop.add_signal_handler(sig, _cancel, sig, None)
|
|
176
|
+
self._installed.append((sig, "loop"))
|
|
177
|
+
except (NotImplementedError, RuntimeError):
|
|
178
|
+
try:
|
|
179
|
+
self._previous[sig] = signal.getsignal(sig)
|
|
180
|
+
signal.signal(sig, _cancel)
|
|
181
|
+
self._installed.append((sig, "signal"))
|
|
182
|
+
except (ValueError, OSError):
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
return self.restore
|
|
186
|
+
|
|
187
|
+
def restore(self) -> None:
|
|
188
|
+
"""Restore signal handlers registered by this scope."""
|
|
189
|
+
target_loop = self.loop or asyncio.get_event_loop()
|
|
190
|
+
for sig, mechanism in self._installed:
|
|
191
|
+
if mechanism == "loop":
|
|
192
|
+
try:
|
|
193
|
+
target_loop.remove_signal_handler(sig)
|
|
194
|
+
except (NotImplementedError, RuntimeError):
|
|
195
|
+
pass
|
|
196
|
+
else:
|
|
197
|
+
try:
|
|
198
|
+
signal.signal(sig, self._previous[sig])
|
|
199
|
+
except (KeyError, ValueError, OSError):
|
|
200
|
+
pass
|
|
201
|
+
self._installed.clear()
|
|
202
|
+
self._previous.clear()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# Default retryable exception types. Broad transport errors are kept as the
|
|
206
|
+
# outer net, but HTTP-status-aware retrying is layered on top via
|
|
207
|
+
# ``_should_retry`` which inspects 429/503 first (FR-ERR-3, BUG-SUB-002).
|
|
208
|
+
DEFAULT_RETRY_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
|
209
|
+
requests.RequestException,
|
|
210
|
+
requests.ConnectionError,
|
|
211
|
+
asyncio.TimeoutError,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _calculate_delay(
|
|
216
|
+
initial_delay_ms: float,
|
|
217
|
+
attempt: int,
|
|
218
|
+
max_delay_ms: float,
|
|
219
|
+
multiplier: float,
|
|
220
|
+
jitter: bool,
|
|
221
|
+
) -> float:
|
|
222
|
+
"""Calculate retry delay (returned in **seconds**) with backoff and jitter.
|
|
223
|
+
|
|
224
|
+
Formula::
|
|
225
|
+
|
|
226
|
+
delay_ms = min(initial_ms * (multiplier ** attempt), max_ms)
|
|
227
|
+
delay_s = delay_ms / 1000
|
|
228
|
+
with jitter: delay_s *= (1 + random.uniform(-0.1, 0.1))
|
|
229
|
+
|
|
230
|
+
Parameters
|
|
231
|
+
----------
|
|
232
|
+
initial_delay_ms : float
|
|
233
|
+
Base delay in milliseconds.
|
|
234
|
+
attempt : int
|
|
235
|
+
Current attempt number (0-indexed).
|
|
236
|
+
max_delay_ms : float
|
|
237
|
+
Maximum delay cap in milliseconds.
|
|
238
|
+
multiplier : float
|
|
239
|
+
Backoff multiplier.
|
|
240
|
+
jitter : bool
|
|
241
|
+
Whether to apply random jitter.
|
|
242
|
+
|
|
243
|
+
Returns
|
|
244
|
+
-------
|
|
245
|
+
float
|
|
246
|
+
Calculated delay in **seconds** (ready for ``asyncio.sleep``).
|
|
247
|
+
"""
|
|
248
|
+
delay_ms = min(initial_delay_ms * (multiplier**attempt), max_delay_ms)
|
|
249
|
+
delay_s = delay_ms / 1000.0
|
|
250
|
+
|
|
251
|
+
if jitter:
|
|
252
|
+
jitter_factor = 1.0 + random.uniform(-0.1, 0.1) # nosec B311 - retry jitter is not security-sensitive.
|
|
253
|
+
delay_s = delay_s * jitter_factor
|
|
254
|
+
|
|
255
|
+
return delay_s
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class RetryHandler:
|
|
259
|
+
"""Configurable retry handler with exponential backoff and jitter.
|
|
260
|
+
|
|
261
|
+
Supports both decorator and async context manager protocols, enforces
|
|
262
|
+
per-call timeouts via ``asyncio.wait_for()`` (ADR-002, BUG-SUB-001),
|
|
263
|
+
and treats HTTP 429/503 as retryable per FR-ERR-3 (BUG-SUB-002).
|
|
264
|
+
Logs retry attempts to stderr via NDJSON structured logging (ADR-005).
|
|
265
|
+
|
|
266
|
+
Parameters
|
|
267
|
+
----------
|
|
268
|
+
max_retries : int, optional
|
|
269
|
+
Maximum number of retry attempts. Read from env var if not set.
|
|
270
|
+
base_delay : float, optional
|
|
271
|
+
Base delay in **milliseconds** for backoff. Read from env var if
|
|
272
|
+
not set. Kept under the legacy name for call-site compatibility;
|
|
273
|
+
internally converted to seconds before sleeping.
|
|
274
|
+
max_delay : float, optional
|
|
275
|
+
Maximum delay cap in **milliseconds**. Read from env var if not set.
|
|
276
|
+
multiplier : float, optional
|
|
277
|
+
Backoff multiplier. Read from env var if not set.
|
|
278
|
+
jitter : bool, optional
|
|
279
|
+
Enable ±10% random jitter. Read from env var if not set.
|
|
280
|
+
retry_on : tuple of Exception types, optional
|
|
281
|
+
Exception types to retry on. Defaults to HTTP-related exceptions.
|
|
282
|
+
timeout_s : float, optional
|
|
283
|
+
Per-call timeout in seconds. ``None`` disables the timeout wrapper.
|
|
284
|
+
Read from env var if not set.
|
|
285
|
+
|
|
286
|
+
Examples
|
|
287
|
+
--------
|
|
288
|
+
As decorator::
|
|
289
|
+
|
|
290
|
+
>>> handler = RetryHandler(max_retries=3)
|
|
291
|
+
>>> @handler
|
|
292
|
+
... async def call_api():
|
|
293
|
+
... return await session.get(url)
|
|
294
|
+
|
|
295
|
+
As context manager::
|
|
296
|
+
|
|
297
|
+
>>> handler = RetryHandler()
|
|
298
|
+
>>> async with handler as h:
|
|
299
|
+
... result = await h.execute(session.get(url))
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
def __init__(
|
|
303
|
+
self,
|
|
304
|
+
max_retries: int | None = None,
|
|
305
|
+
base_delay: float | None = None,
|
|
306
|
+
max_delay: float | None = None,
|
|
307
|
+
multiplier: float | None = None,
|
|
308
|
+
jitter: bool | None = None,
|
|
309
|
+
retry_on: tuple[type[BaseException], ...] | None = None,
|
|
310
|
+
timeout_s: float | None = ..., # type: ignore[assignment]
|
|
311
|
+
) -> None:
|
|
312
|
+
# ``max_retries`` is accepted for call-site backwards compatibility;
|
|
313
|
+
# canonical name going forward is "max_attempts".
|
|
314
|
+
self.max_retries = (
|
|
315
|
+
max_retries
|
|
316
|
+
if max_retries is not None
|
|
317
|
+
else int(os.environ.get(ENV_MAX_ATTEMPTS, DEFAULT_MAX_ATTEMPTS))
|
|
318
|
+
)
|
|
319
|
+
# Delays are read from canonical *_MS env vars. Accept explicit
|
|
320
|
+
# constructor overrides in **milliseconds** to match the env contract.
|
|
321
|
+
self.base_delay_ms = (
|
|
322
|
+
base_delay
|
|
323
|
+
if base_delay is not None
|
|
324
|
+
else float(os.environ.get(ENV_INITIAL_DELAY_MS, DEFAULT_INITIAL_DELAY_MS))
|
|
325
|
+
)
|
|
326
|
+
self.max_delay_ms = (
|
|
327
|
+
max_delay
|
|
328
|
+
if max_delay is not None
|
|
329
|
+
else float(os.environ.get(ENV_MAX_DELAY_MS, DEFAULT_MAX_DELAY_MS))
|
|
330
|
+
)
|
|
331
|
+
self.multiplier = (
|
|
332
|
+
multiplier
|
|
333
|
+
if multiplier is not None
|
|
334
|
+
else float(os.environ.get(ENV_MULTIPLIER, DEFAULT_MULTIPLIER))
|
|
335
|
+
)
|
|
336
|
+
self.jitter = (
|
|
337
|
+
jitter
|
|
338
|
+
if jitter is not None
|
|
339
|
+
else _parse_bool_env(ENV_JITTER, DEFAULT_JITTER)
|
|
340
|
+
)
|
|
341
|
+
self.retry_on = retry_on if retry_on is not None else DEFAULT_RETRY_EXCEPTIONS
|
|
342
|
+
# Sentinel default: when caller passes nothing, read env var or fall
|
|
343
|
+
# back to the canonical timeout. ``None`` explicitly disables the
|
|
344
|
+
# wait_for wrapper (used by unit tests that want raw control).
|
|
345
|
+
if timeout_s is ...:
|
|
346
|
+
timeout_s = float(os.environ.get(ENV_TIMEOUT_S, DEFAULT_TIMEOUT_S))
|
|
347
|
+
self.timeout_s: float | None = timeout_s
|
|
348
|
+
|
|
349
|
+
# Backwards-compatible alias: ``base_delay``/``max_delay`` historically
|
|
350
|
+
# returned seconds. To preserve existing call sites and repr output we
|
|
351
|
+
# expose the values in **seconds** via these properties while keeping the
|
|
352
|
+
# canonical-ms value as the source of truth internally.
|
|
353
|
+
@property
|
|
354
|
+
def base_delay(self) -> float:
|
|
355
|
+
"""Base delay in seconds (derived from ``base_delay_ms``)."""
|
|
356
|
+
return self.base_delay_ms / 1000.0
|
|
357
|
+
|
|
358
|
+
@property
|
|
359
|
+
def max_delay(self) -> float:
|
|
360
|
+
"""Maximum delay cap in seconds (derived from ``max_delay_ms``)."""
|
|
361
|
+
return self.max_delay_ms / 1000.0
|
|
362
|
+
|
|
363
|
+
def _should_retry(self, exception: BaseException) -> bool:
|
|
364
|
+
"""Check if an exception should trigger a retry.
|
|
365
|
+
|
|
366
|
+
Logic:
|
|
367
|
+
1. Signal-triggered cancellation never retries; it exits as timeout.
|
|
368
|
+
2. If an HTTP status is present, retry only 429/503.
|
|
369
|
+
3. If no HTTP status is present, retry configured exception types.
|
|
370
|
+
|
|
371
|
+
Parameters
|
|
372
|
+
----------
|
|
373
|
+
exception : BaseException
|
|
374
|
+
The exception to check.
|
|
375
|
+
|
|
376
|
+
Returns
|
|
377
|
+
-------
|
|
378
|
+
bool
|
|
379
|
+
True if the exception is retryable.
|
|
380
|
+
"""
|
|
381
|
+
if isinstance(exception, SignalCancellationError):
|
|
382
|
+
return False
|
|
383
|
+
|
|
384
|
+
status = _get_http_status(exception)
|
|
385
|
+
if status is not None:
|
|
386
|
+
return status in RETRYABLE_HTTP_STATUSES
|
|
387
|
+
|
|
388
|
+
return is_sdk_retryable_transport(exception) or isinstance(
|
|
389
|
+
exception, self.retry_on
|
|
390
|
+
) or isinstance(
|
|
391
|
+
exception, asyncio.TimeoutError
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def _execute_with_timeout(
|
|
395
|
+
self,
|
|
396
|
+
coro_func: Callable[..., Any],
|
|
397
|
+
*args: Any,
|
|
398
|
+
**kwargs: Any,
|
|
399
|
+
) -> Any:
|
|
400
|
+
"""Build one awaitable attempt, applying the configured timeout."""
|
|
401
|
+
coroutine = coro_func(*args, **kwargs)
|
|
402
|
+
if self.timeout_s is None:
|
|
403
|
+
return coroutine
|
|
404
|
+
return asyncio.wait_for(coroutine, timeout=self.timeout_s)
|
|
405
|
+
|
|
406
|
+
async def execute(
|
|
407
|
+
self,
|
|
408
|
+
coro_func: Callable[..., Any],
|
|
409
|
+
*args: Any,
|
|
410
|
+
**kwargs: Any,
|
|
411
|
+
) -> Any:
|
|
412
|
+
"""Execute an async callable with retry logic and per-call timeout.
|
|
413
|
+
|
|
414
|
+
Wraps each attempt in ``asyncio.wait_for()`` so that hung
|
|
415
|
+
transports cannot stall the retry loop (BUG-SUB-001). Timeouts
|
|
416
|
+
raised by ``wait_for`` surface as ``asyncio.TimeoutError`` and
|
|
417
|
+
are retried like any other configured exception.
|
|
418
|
+
|
|
419
|
+
Parameters
|
|
420
|
+
----------
|
|
421
|
+
coro_func : Callable
|
|
422
|
+
Async callable to execute.
|
|
423
|
+
*args
|
|
424
|
+
Positional arguments for the callable.
|
|
425
|
+
**kwargs
|
|
426
|
+
Keyword arguments for the callable.
|
|
427
|
+
|
|
428
|
+
Returns
|
|
429
|
+
-------
|
|
430
|
+
Any
|
|
431
|
+
Result from the callable.
|
|
432
|
+
|
|
433
|
+
Raises
|
|
434
|
+
------
|
|
435
|
+
BaseException
|
|
436
|
+
The last exception raised if all retries are exhausted.
|
|
437
|
+
"""
|
|
438
|
+
last_exception: BaseException | None = None
|
|
439
|
+
|
|
440
|
+
for attempt in range(self.max_retries + 1):
|
|
441
|
+
signal_scope = _SignalCancellationScope()
|
|
442
|
+
restore_signals = signal_scope.install()
|
|
443
|
+
try:
|
|
444
|
+
try:
|
|
445
|
+
return await self._execute_with_timeout(coro_func, *args, **kwargs)
|
|
446
|
+
except asyncio.CancelledError as exc:
|
|
447
|
+
if signal_scope.signum is not None:
|
|
448
|
+
signal_name = _signal_name(signal_scope.signum)
|
|
449
|
+
raise SignalCancellationError(
|
|
450
|
+
f"Operation cancelled by {signal_name}"
|
|
451
|
+
) from exc
|
|
452
|
+
raise
|
|
453
|
+
except BaseException as exc:
|
|
454
|
+
if not self._should_retry(exc):
|
|
455
|
+
raise
|
|
456
|
+
|
|
457
|
+
last_exception = exc
|
|
458
|
+
|
|
459
|
+
if attempt < self.max_retries:
|
|
460
|
+
delay_s = _calculate_delay(
|
|
461
|
+
self.base_delay_ms,
|
|
462
|
+
attempt,
|
|
463
|
+
self.max_delay_ms,
|
|
464
|
+
self.multiplier,
|
|
465
|
+
self.jitter,
|
|
466
|
+
)
|
|
467
|
+
delay_ms = int(delay_s * 1000)
|
|
468
|
+
|
|
469
|
+
logger.warning(
|
|
470
|
+
f"Retry attempt {attempt + 1}/{self.max_retries} "
|
|
471
|
+
f"after {type(exc).__name__}: delaying {delay_ms}ms",
|
|
472
|
+
extra={
|
|
473
|
+
"attempt": attempt + 1,
|
|
474
|
+
"delay_ms": delay_ms,
|
|
475
|
+
"http_status": _get_http_status(exc),
|
|
476
|
+
},
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
await asyncio.sleep(delay_s)
|
|
480
|
+
else:
|
|
481
|
+
logger.error(
|
|
482
|
+
f"Retry exhausted after {self.max_retries + 1} attempts: "
|
|
483
|
+
f"{type(exc).__name__}",
|
|
484
|
+
extra={
|
|
485
|
+
"attempt": attempt + 1,
|
|
486
|
+
"http_status": _get_http_status(exc),
|
|
487
|
+
},
|
|
488
|
+
)
|
|
489
|
+
finally:
|
|
490
|
+
restore_signals()
|
|
491
|
+
|
|
492
|
+
raise last_exception # type: ignore[misc]
|
|
493
|
+
|
|
494
|
+
async def execute_traced(
|
|
495
|
+
self,
|
|
496
|
+
tracing: "TracingProvider",
|
|
497
|
+
coro_func: Callable[..., Any],
|
|
498
|
+
*args: Any,
|
|
499
|
+
supplied: "B3Context | None" = None,
|
|
500
|
+
**kwargs: Any,
|
|
501
|
+
) -> Any:
|
|
502
|
+
"""Execute every retry attempt under one isolated B3 context."""
|
|
503
|
+
with tracing.scope(supplied):
|
|
504
|
+
return await self.execute(coro_func, *args, **kwargs)
|
|
505
|
+
|
|
506
|
+
def __call__(self, func: F) -> F:
|
|
507
|
+
"""Decorator protocol: wrap an async function with retry logic.
|
|
508
|
+
|
|
509
|
+
Parameters
|
|
510
|
+
----------
|
|
511
|
+
func : Callable
|
|
512
|
+
Async function to wrap.
|
|
513
|
+
|
|
514
|
+
Returns
|
|
515
|
+
-------
|
|
516
|
+
Callable
|
|
517
|
+
Wrapped async function with retry logic.
|
|
518
|
+
"""
|
|
519
|
+
|
|
520
|
+
@wraps(func)
|
|
521
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
522
|
+
return await self.execute(func, *args, **kwargs)
|
|
523
|
+
|
|
524
|
+
return wrapper # type: ignore[return-value]
|
|
525
|
+
|
|
526
|
+
@asynccontextmanager
|
|
527
|
+
async def context(self):
|
|
528
|
+
"""Async context manager protocol for retry handling.
|
|
529
|
+
|
|
530
|
+
Usage::
|
|
531
|
+
|
|
532
|
+
>>> handler = RetryHandler()
|
|
533
|
+
>>> async with handler.context() as h:
|
|
534
|
+
... result = await h.execute(some_async_call)
|
|
535
|
+
"""
|
|
536
|
+
yield self
|
|
537
|
+
|
|
538
|
+
def __repr__(self) -> str:
|
|
539
|
+
"""Return string representation of RetryHandler configuration."""
|
|
540
|
+
return (
|
|
541
|
+
f"RetryHandler(max_retries={self.max_retries}, "
|
|
542
|
+
f"base_delay={self.base_delay}, "
|
|
543
|
+
f"max_delay={self.max_delay}, "
|
|
544
|
+
f"multiplier={self.multiplier}, "
|
|
545
|
+
f"jitter={self.jitter})"
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def install_signal_cancellation(
|
|
550
|
+
loop: asyncio.AbstractEventLoop | None = None,
|
|
551
|
+
) -> Callable[[], None]:
|
|
552
|
+
"""Install SIGINT/SIGTERM handlers that cancel the running task.
|
|
553
|
+
|
|
554
|
+
Implements ADR-002 §Consequences: SIGINT/SIGTERM must cancel the
|
|
555
|
+
``asyncio.wait_for`` wait. The handler cancels the current task so
|
|
556
|
+
that ``asyncio.TimeoutError``/``CancelledError`` propagates to the
|
|
557
|
+
retry loop and exits to ``ErrorSerializer`` which maps it to exit
|
|
558
|
+
code 5 (TimeoutError per ADR-001).
|
|
559
|
+
|
|
560
|
+
Parameters
|
|
561
|
+
----------
|
|
562
|
+
loop : AbstractEventLoop, optional
|
|
563
|
+
Event loop to register handlers against. Defaults to the running
|
|
564
|
+
loop if one exists.
|
|
565
|
+
|
|
566
|
+
Returns
|
|
567
|
+
-------
|
|
568
|
+
Callable[[], None]
|
|
569
|
+
Restore function that removes the installed handlers. Call it in
|
|
570
|
+
a ``finally:`` block to avoid leaking handlers across invocations.
|
|
571
|
+
"""
|
|
572
|
+
target_loop = loop or asyncio.get_event_loop()
|
|
573
|
+
if not target_loop.is_running():
|
|
574
|
+
# Nothing to cancel yet; callers should install during the run.
|
|
575
|
+
return lambda: None
|
|
576
|
+
|
|
577
|
+
main_task = asyncio.current_task(target_loop)
|
|
578
|
+
|
|
579
|
+
def _cancel(signum: int, frame: Any = None) -> None:
|
|
580
|
+
if main_task is not None and not main_task.done():
|
|
581
|
+
target_loop.call_soon_threadsafe(main_task.cancel)
|
|
582
|
+
|
|
583
|
+
installed: list[int] = []
|
|
584
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
585
|
+
try:
|
|
586
|
+
target_loop.add_signal_handler(sig, _cancel, sig, None)
|
|
587
|
+
installed.append(sig)
|
|
588
|
+
except (NotImplementedError, RuntimeError):
|
|
589
|
+
# add_signal_handler is unavailable on Windows / non-main thread.
|
|
590
|
+
# Fall back to the regular signal API where possible.
|
|
591
|
+
try:
|
|
592
|
+
previous = signal.getsignal(sig)
|
|
593
|
+
signal.signal(sig, _cancel)
|
|
594
|
+
installed.append(sig)
|
|
595
|
+
# Stash previous so we can restore.
|
|
596
|
+
_PREV_SIGNALS[sig] = previous # type: ignore[assignment]
|
|
597
|
+
except (ValueError, OSError):
|
|
598
|
+
# Giving up silently: signal installation is best-effort.
|
|
599
|
+
pass
|
|
600
|
+
|
|
601
|
+
def _restore() -> None:
|
|
602
|
+
for sig in installed:
|
|
603
|
+
try:
|
|
604
|
+
target_loop.remove_signal_handler(sig)
|
|
605
|
+
except (NotImplementedError, RuntimeError):
|
|
606
|
+
try:
|
|
607
|
+
prev = _PREV_SIGNALS.pop(sig, None)
|
|
608
|
+
if prev is not None:
|
|
609
|
+
signal.signal(sig, prev)
|
|
610
|
+
except (ValueError, OSError):
|
|
611
|
+
pass
|
|
612
|
+
|
|
613
|
+
return _restore
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
# Stores the previous signal handlers so we can restore them on cleanup.
|
|
617
|
+
_PREV_SIGNALS: dict = {}
|