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.
Files changed (89) hide show
  1. pal_found_cli/__init__.py +1 -0
  2. pal_found_cli/_version.py +24 -0
  3. pal_found_cli/admin/__init__.py +2 -0
  4. pal_found_cli/admin/scripts/__init__.py +2 -0
  5. pal_found_cli/admin/scripts/pal_found_admin_cli.py +600 -0
  6. pal_found_cli/aip_agents/__init__.py +1 -0
  7. pal_found_cli/aip_agents/metadata-allow-list.md +20 -0
  8. pal_found_cli/aip_agents/scripts/__init__.py +1 -0
  9. pal_found_cli/aip_agents/scripts/pal_found_aip_agents_cli.py +704 -0
  10. pal_found_cli/audit/__init__.py +1 -0
  11. pal_found_cli/audit/metadata-allow-list.md +4 -0
  12. pal_found_cli/audit/scripts/__init__.py +1 -0
  13. pal_found_cli/audit/scripts/pal_found_audit_cli.py +387 -0
  14. pal_found_cli/checkpoints/__init__.py +9 -0
  15. pal_found_cli/checkpoints/metadata-allow-list.md +7 -0
  16. pal_found_cli/checkpoints/scripts/__init__.py +1 -0
  17. pal_found_cli/checkpoints/scripts/pal_found_checkpoints_cli.py +527 -0
  18. pal_found_cli/common/__init__.py +56 -0
  19. pal_found_cli/common/access_control_guard.py +591 -0
  20. pal_found_cli/common/async_client_factory.py +161 -0
  21. pal_found_cli/common/auth_provider.py +82 -0
  22. pal_found_cli/common/binary_download_handler.py +288 -0
  23. pal_found_cli/common/config_loader.py +388 -0
  24. pal_found_cli/common/error_serializer.py +412 -0
  25. pal_found_cli/common/log_setup.py +241 -0
  26. pal_found_cli/common/metadata-allow-list.md +169 -0
  27. pal_found_cli/common/output_formatter.py +267 -0
  28. pal_found_cli/common/pagination_helper.py +212 -0
  29. pal_found_cli/common/retry.py +617 -0
  30. pal_found_cli/common/sdk_error_utils.py +112 -0
  31. pal_found_cli/common/session_manager.py +461 -0
  32. pal_found_cli/common/tracing_provider.py +107 -0
  33. pal_found_cli/connectivity/__init__.py +9 -0
  34. pal_found_cli/connectivity/metadata-allow-list.md +24 -0
  35. pal_found_cli/connectivity/scripts/__init__.py +1 -0
  36. pal_found_cli/connectivity/scripts/pal_found_connectivity_cli.py +720 -0
  37. pal_found_cli/data_health/__init__.py +9 -0
  38. pal_found_cli/data_health/metadata-allow-list.md +10 -0
  39. pal_found_cli/data_health/scripts/__init__.py +1 -0
  40. pal_found_cli/data_health/scripts/pal_found_data_health_cli.py +458 -0
  41. pal_found_cli/datasets/__init__.py +1 -0
  42. pal_found_cli/datasets/scripts/__init__.py +1 -0
  43. pal_found_cli/datasets/scripts/pal_found_datasets_cli.py +732 -0
  44. pal_found_cli/filesystem/__init__.py +2 -0
  45. pal_found_cli/filesystem/scripts/__init__.py +2 -0
  46. pal_found_cli/filesystem/scripts/pal_found_filesystem_cli.py +659 -0
  47. pal_found_cli/functions/__init__.py +2 -0
  48. pal_found_cli/functions/scripts/__init__.py +2 -0
  49. pal_found_cli/functions/scripts/pal_found_functions_cli.py +389 -0
  50. pal_found_cli/language_models/__init__.py +1 -0
  51. pal_found_cli/language_models/metadata-allow-list.md +6 -0
  52. pal_found_cli/language_models/scripts/__init__.py +1 -0
  53. pal_found_cli/language_models/scripts/pal_found_language_models_cli.py +342 -0
  54. pal_found_cli/media_sets/__init__.py +9 -0
  55. pal_found_cli/media_sets/metadata-allow-list.md +23 -0
  56. pal_found_cli/media_sets/scripts/__init__.py +1 -0
  57. pal_found_cli/media_sets/scripts/pal_found_media_sets_cli.py +642 -0
  58. pal_found_cli/models/__init__.py +5 -0
  59. pal_found_cli/models/metadata-allow-list.md +27 -0
  60. pal_found_cli/models/scripts/__init__.py +1 -0
  61. pal_found_cli/models/scripts/pal_found_models_cli.py +714 -0
  62. pal_found_cli/ontologies/__init__.py +1 -0
  63. pal_found_cli/ontologies/scripts/__init__.py +1 -0
  64. pal_found_cli/ontologies/scripts/pal_found_ontologies_cli.py +1225 -0
  65. pal_found_cli/orchestration/__init__.py +9 -0
  66. pal_found_cli/orchestration/metadata-allow-list.md +24 -0
  67. pal_found_cli/orchestration/scripts/__init__.py +1 -0
  68. pal_found_cli/orchestration/scripts/pal_found_orchestration_cli.py +640 -0
  69. pal_found_cli/sql_queries/__init__.py +9 -0
  70. pal_found_cli/sql_queries/metadata-allow-list.md +9 -0
  71. pal_found_cli/sql_queries/scripts/__init__.py +1 -0
  72. pal_found_cli/sql_queries/scripts/pal_found_sql_queries_cli.py +514 -0
  73. pal_found_cli/streams/__init__.py +9 -0
  74. pal_found_cli/streams/metadata-allow-list.md +19 -0
  75. pal_found_cli/streams/scripts/__init__.py +1 -0
  76. pal_found_cli/streams/scripts/pal_found_streams_cli.py +615 -0
  77. pal_found_cli/third_party_applications/__init__.py +9 -0
  78. pal_found_cli/third_party_applications/metadata-allow-list.md +13 -0
  79. pal_found_cli/third_party_applications/scripts/__init__.py +1 -0
  80. pal_found_cli/third_party_applications/scripts/pal_found_third_party_applications_cli.py +547 -0
  81. pal_found_cli/widgets/__init__.py +9 -0
  82. pal_found_cli/widgets/metadata-allow-list.md +12 -0
  83. pal_found_cli/widgets/scripts/__init__.py +1 -0
  84. pal_found_cli/widgets/scripts/pal_found_widgets_cli.py +571 -0
  85. pal_found_cli-0.1.2.dist-info/METADATA +74 -0
  86. pal_found_cli-0.1.2.dist-info/RECORD +89 -0
  87. pal_found_cli-0.1.2.dist-info/WHEEL +5 -0
  88. pal_found_cli-0.1.2.dist-info/entry_points.txt +19 -0
  89. 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 = {}