proxilion 0.0.1__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.
- proxilion/__init__.py +136 -0
- proxilion/audit/__init__.py +133 -0
- proxilion/audit/base_exporters.py +527 -0
- proxilion/audit/compliance/__init__.py +130 -0
- proxilion/audit/compliance/base.py +457 -0
- proxilion/audit/compliance/eu_ai_act.py +603 -0
- proxilion/audit/compliance/iso27001.py +544 -0
- proxilion/audit/compliance/soc2.py +491 -0
- proxilion/audit/events.py +493 -0
- proxilion/audit/explainability.py +1173 -0
- proxilion/audit/exporters/__init__.py +58 -0
- proxilion/audit/exporters/aws_s3.py +636 -0
- proxilion/audit/exporters/azure_storage.py +608 -0
- proxilion/audit/exporters/cloud_base.py +468 -0
- proxilion/audit/exporters/gcp_storage.py +570 -0
- proxilion/audit/exporters/multi_exporter.py +498 -0
- proxilion/audit/hash_chain.py +652 -0
- proxilion/audit/logger.py +543 -0
- proxilion/caching/__init__.py +49 -0
- proxilion/caching/tool_cache.py +633 -0
- proxilion/context/__init__.py +73 -0
- proxilion/context/context_window.py +556 -0
- proxilion/context/message_history.py +505 -0
- proxilion/context/session.py +735 -0
- proxilion/contrib/__init__.py +51 -0
- proxilion/contrib/anthropic.py +609 -0
- proxilion/contrib/google.py +1012 -0
- proxilion/contrib/langchain.py +641 -0
- proxilion/contrib/mcp.py +893 -0
- proxilion/contrib/openai.py +646 -0
- proxilion/core.py +3058 -0
- proxilion/decorators.py +966 -0
- proxilion/engines/__init__.py +287 -0
- proxilion/engines/base.py +266 -0
- proxilion/engines/casbin_engine.py +412 -0
- proxilion/engines/opa_engine.py +493 -0
- proxilion/engines/simple.py +437 -0
- proxilion/exceptions.py +887 -0
- proxilion/guards/__init__.py +54 -0
- proxilion/guards/input_guard.py +522 -0
- proxilion/guards/output_guard.py +634 -0
- proxilion/observability/__init__.py +198 -0
- proxilion/observability/cost_tracker.py +866 -0
- proxilion/observability/hooks.py +683 -0
- proxilion/observability/metrics.py +798 -0
- proxilion/observability/session_cost_tracker.py +1063 -0
- proxilion/policies/__init__.py +67 -0
- proxilion/policies/base.py +304 -0
- proxilion/policies/builtin.py +486 -0
- proxilion/policies/registry.py +376 -0
- proxilion/providers/__init__.py +201 -0
- proxilion/providers/adapter.py +468 -0
- proxilion/providers/anthropic_adapter.py +330 -0
- proxilion/providers/gemini_adapter.py +391 -0
- proxilion/providers/openai_adapter.py +294 -0
- proxilion/py.typed +0 -0
- proxilion/resilience/__init__.py +81 -0
- proxilion/resilience/degradation.py +615 -0
- proxilion/resilience/fallback.py +555 -0
- proxilion/resilience/retry.py +554 -0
- proxilion/scheduling/__init__.py +57 -0
- proxilion/scheduling/priority_queue.py +419 -0
- proxilion/scheduling/scheduler.py +459 -0
- proxilion/security/__init__.py +244 -0
- proxilion/security/agent_trust.py +968 -0
- proxilion/security/behavioral_drift.py +794 -0
- proxilion/security/cascade_protection.py +869 -0
- proxilion/security/circuit_breaker.py +428 -0
- proxilion/security/cost_limiter.py +690 -0
- proxilion/security/idor_protection.py +460 -0
- proxilion/security/intent_capsule.py +849 -0
- proxilion/security/intent_validator.py +495 -0
- proxilion/security/memory_integrity.py +767 -0
- proxilion/security/rate_limiter.py +509 -0
- proxilion/security/scope_enforcer.py +680 -0
- proxilion/security/sequence_validator.py +636 -0
- proxilion/security/trust_boundaries.py +784 -0
- proxilion/streaming/__init__.py +70 -0
- proxilion/streaming/detector.py +761 -0
- proxilion/streaming/transformer.py +674 -0
- proxilion/timeouts/__init__.py +55 -0
- proxilion/timeouts/decorators.py +477 -0
- proxilion/timeouts/manager.py +545 -0
- proxilion/tools/__init__.py +69 -0
- proxilion/tools/decorators.py +493 -0
- proxilion/tools/registry.py +732 -0
- proxilion/types.py +339 -0
- proxilion/validation/__init__.py +93 -0
- proxilion/validation/pydantic_schema.py +351 -0
- proxilion/validation/schema.py +651 -0
- proxilion-0.0.1.dist-info/METADATA +872 -0
- proxilion-0.0.1.dist-info/RECORD +94 -0
- proxilion-0.0.1.dist-info/WHEEL +4 -0
- proxilion-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Retry logic with exponential backoff for AI operations.
|
|
3
|
+
|
|
4
|
+
Provides configurable retry policies with exponential backoff,
|
|
5
|
+
jitter, and customizable retry conditions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import functools
|
|
12
|
+
import inspect
|
|
13
|
+
import logging
|
|
14
|
+
import random
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import Awaitable, Callable
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import Any, ParamSpec, TypeVar
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
P = ParamSpec("P")
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class RetryPolicy:
|
|
30
|
+
"""
|
|
31
|
+
Configuration for retry behavior.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
max_attempts: Maximum number of attempts (including the first try).
|
|
35
|
+
base_delay: Base delay between retries in seconds.
|
|
36
|
+
max_delay: Maximum delay between retries in seconds.
|
|
37
|
+
exponential_base: Base for exponential backoff calculation.
|
|
38
|
+
jitter: Jitter factor as a fraction (0.1 = +/- 10%).
|
|
39
|
+
retryable_exceptions: Tuple of exception types that trigger retry.
|
|
40
|
+
retry_on: Optional custom function to determine if retry should occur.
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
>>> policy = RetryPolicy(
|
|
44
|
+
... max_attempts=5,
|
|
45
|
+
... base_delay=1.0,
|
|
46
|
+
... max_delay=60.0,
|
|
47
|
+
... exponential_base=2.0,
|
|
48
|
+
... jitter=0.1,
|
|
49
|
+
... )
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
max_attempts: int = 3
|
|
53
|
+
base_delay: float = 1.0
|
|
54
|
+
max_delay: float = 30.0
|
|
55
|
+
exponential_base: float = 2.0
|
|
56
|
+
jitter: float = 0.1
|
|
57
|
+
retryable_exceptions: tuple[type[Exception], ...] = (
|
|
58
|
+
TimeoutError,
|
|
59
|
+
ConnectionError,
|
|
60
|
+
OSError,
|
|
61
|
+
)
|
|
62
|
+
retry_on: Callable[[Exception], bool] | None = None
|
|
63
|
+
|
|
64
|
+
def __post_init__(self) -> None:
|
|
65
|
+
"""Validate policy parameters."""
|
|
66
|
+
if self.max_attempts < 1:
|
|
67
|
+
raise ValueError("max_attempts must be at least 1")
|
|
68
|
+
if self.base_delay < 0:
|
|
69
|
+
raise ValueError("base_delay must be non-negative")
|
|
70
|
+
if self.max_delay < self.base_delay:
|
|
71
|
+
raise ValueError("max_delay must be >= base_delay")
|
|
72
|
+
if self.exponential_base < 1:
|
|
73
|
+
raise ValueError("exponential_base must be >= 1")
|
|
74
|
+
if not 0 <= self.jitter <= 1:
|
|
75
|
+
raise ValueError("jitter must be between 0 and 1")
|
|
76
|
+
|
|
77
|
+
def calculate_delay(self, attempt: int) -> float:
|
|
78
|
+
"""
|
|
79
|
+
Calculate delay for a given attempt number.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
attempt: The attempt number (1-indexed).
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Delay in seconds with jitter applied.
|
|
86
|
+
"""
|
|
87
|
+
# Exponential backoff: base_delay * (exponential_base ^ (attempt - 1))
|
|
88
|
+
delay = self.base_delay * (self.exponential_base ** (attempt - 1))
|
|
89
|
+
|
|
90
|
+
# Cap at max_delay
|
|
91
|
+
delay = min(delay, self.max_delay)
|
|
92
|
+
|
|
93
|
+
# Apply jitter
|
|
94
|
+
if self.jitter > 0:
|
|
95
|
+
jitter_range = delay * self.jitter
|
|
96
|
+
delay = delay + random.uniform(-jitter_range, jitter_range)
|
|
97
|
+
delay = max(0, delay) # Ensure non-negative
|
|
98
|
+
|
|
99
|
+
return delay
|
|
100
|
+
|
|
101
|
+
def should_retry(self, exception: Exception, attempt: int) -> bool:
|
|
102
|
+
"""
|
|
103
|
+
Determine if a retry should be attempted.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
exception: The exception that occurred.
|
|
107
|
+
attempt: Current attempt number (1-indexed).
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
True if retry should be attempted.
|
|
111
|
+
"""
|
|
112
|
+
if attempt >= self.max_attempts:
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
# Check custom retry condition
|
|
116
|
+
if self.retry_on is not None:
|
|
117
|
+
return self.retry_on(exception)
|
|
118
|
+
|
|
119
|
+
# Check if exception type is retryable
|
|
120
|
+
return isinstance(exception, self.retryable_exceptions)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class RetryContext:
|
|
125
|
+
"""
|
|
126
|
+
Context information for a retry attempt.
|
|
127
|
+
|
|
128
|
+
Attributes:
|
|
129
|
+
attempt: Current attempt number (1-indexed).
|
|
130
|
+
total_delay: Total delay accumulated across all retries.
|
|
131
|
+
last_exception: The exception that triggered this retry.
|
|
132
|
+
should_retry: Whether another retry will be attempted.
|
|
133
|
+
started_at: When the retry sequence started.
|
|
134
|
+
policy: The retry policy being used.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
attempt: int
|
|
138
|
+
total_delay: float
|
|
139
|
+
last_exception: Exception | None
|
|
140
|
+
should_retry: bool
|
|
141
|
+
started_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
142
|
+
policy: RetryPolicy | None = None
|
|
143
|
+
|
|
144
|
+
def elapsed(self) -> float:
|
|
145
|
+
"""Get elapsed time since retry sequence started."""
|
|
146
|
+
return (datetime.now(timezone.utc) - self.started_at).total_seconds()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class RetryStats:
|
|
151
|
+
"""
|
|
152
|
+
Statistics for retry operations.
|
|
153
|
+
|
|
154
|
+
Attributes:
|
|
155
|
+
total_attempts: Total number of attempts made.
|
|
156
|
+
successful_attempts: Number of successful completions.
|
|
157
|
+
failed_attempts: Number of failed attempts (excluding final failure).
|
|
158
|
+
total_delay: Total time spent in delays.
|
|
159
|
+
exceptions: List of exceptions encountered.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
total_attempts: int = 0
|
|
163
|
+
successful_attempts: int = 0
|
|
164
|
+
failed_attempts: int = 0
|
|
165
|
+
total_delay: float = 0.0
|
|
166
|
+
exceptions: list[Exception] = field(default_factory=list)
|
|
167
|
+
|
|
168
|
+
def record_attempt(
|
|
169
|
+
self, success: bool, delay: float = 0.0, exception: Exception | None = None
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Record an attempt."""
|
|
172
|
+
self.total_attempts += 1
|
|
173
|
+
self.total_delay += delay
|
|
174
|
+
if success:
|
|
175
|
+
self.successful_attempts += 1
|
|
176
|
+
else:
|
|
177
|
+
self.failed_attempts += 1
|
|
178
|
+
if exception:
|
|
179
|
+
self.exceptions.append(exception)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def success_rate(self) -> float:
|
|
183
|
+
"""Calculate success rate."""
|
|
184
|
+
if self.total_attempts == 0:
|
|
185
|
+
return 0.0
|
|
186
|
+
return self.successful_attempts / self.total_attempts
|
|
187
|
+
|
|
188
|
+
def to_dict(self) -> dict[str, Any]:
|
|
189
|
+
"""Convert to dictionary."""
|
|
190
|
+
return {
|
|
191
|
+
"total_attempts": self.total_attempts,
|
|
192
|
+
"successful_attempts": self.successful_attempts,
|
|
193
|
+
"failed_attempts": self.failed_attempts,
|
|
194
|
+
"total_delay": self.total_delay,
|
|
195
|
+
"success_rate": self.success_rate,
|
|
196
|
+
"exception_types": [type(e).__name__ for e in self.exceptions],
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# Default retry policy
|
|
201
|
+
DEFAULT_RETRY_POLICY = RetryPolicy(
|
|
202
|
+
max_attempts=3,
|
|
203
|
+
base_delay=1.0,
|
|
204
|
+
max_delay=30.0,
|
|
205
|
+
exponential_base=2.0,
|
|
206
|
+
jitter=0.1,
|
|
207
|
+
retryable_exceptions=(
|
|
208
|
+
TimeoutError,
|
|
209
|
+
ConnectionError,
|
|
210
|
+
OSError,
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def retry_with_backoff(
|
|
216
|
+
policy: RetryPolicy | None = None,
|
|
217
|
+
on_retry: Callable[[RetryContext], None] | None = None,
|
|
218
|
+
on_success: Callable[[T, RetryStats], None] | None = None,
|
|
219
|
+
on_failure: Callable[[Exception, RetryStats], None] | None = None,
|
|
220
|
+
) -> Callable[[Callable[P, T]], Callable[P, T]]:
|
|
221
|
+
"""
|
|
222
|
+
Decorator that retries a function with exponential backoff.
|
|
223
|
+
|
|
224
|
+
Works with both synchronous and asynchronous functions.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
policy: Retry policy to use. Defaults to DEFAULT_RETRY_POLICY.
|
|
228
|
+
on_retry: Callback invoked before each retry attempt.
|
|
229
|
+
on_success: Callback invoked on successful completion.
|
|
230
|
+
on_failure: Callback invoked on final failure.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Decorated function with retry behavior.
|
|
234
|
+
|
|
235
|
+
Example:
|
|
236
|
+
>>> @retry_with_backoff(RetryPolicy(max_attempts=3))
|
|
237
|
+
... def call_api():
|
|
238
|
+
... return requests.get("https://api.example.com")
|
|
239
|
+
|
|
240
|
+
>>> @retry_with_backoff(
|
|
241
|
+
... policy=RetryPolicy(max_attempts=5),
|
|
242
|
+
... on_retry=lambda ctx: print(f"Retry {ctx.attempt}")
|
|
243
|
+
... )
|
|
244
|
+
... async def call_async_api():
|
|
245
|
+
... return await client.fetch()
|
|
246
|
+
"""
|
|
247
|
+
effective_policy = policy or DEFAULT_RETRY_POLICY
|
|
248
|
+
|
|
249
|
+
def decorator(func: Callable[P, T]) -> Callable[P, T]:
|
|
250
|
+
if inspect.iscoroutinefunction(func):
|
|
251
|
+
|
|
252
|
+
@functools.wraps(func)
|
|
253
|
+
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
254
|
+
return await retry_async(
|
|
255
|
+
func,
|
|
256
|
+
*args,
|
|
257
|
+
policy=effective_policy,
|
|
258
|
+
on_retry=on_retry,
|
|
259
|
+
on_success=on_success,
|
|
260
|
+
on_failure=on_failure,
|
|
261
|
+
**kwargs,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
return async_wrapper # type: ignore
|
|
265
|
+
else:
|
|
266
|
+
|
|
267
|
+
@functools.wraps(func)
|
|
268
|
+
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
269
|
+
return retry_sync(
|
|
270
|
+
func,
|
|
271
|
+
*args,
|
|
272
|
+
policy=effective_policy,
|
|
273
|
+
on_retry=on_retry,
|
|
274
|
+
on_success=on_success,
|
|
275
|
+
on_failure=on_failure,
|
|
276
|
+
**kwargs,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return sync_wrapper # type: ignore
|
|
280
|
+
|
|
281
|
+
return decorator
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def retry_async(
|
|
285
|
+
func: Callable[..., Awaitable[T]],
|
|
286
|
+
*args: Any,
|
|
287
|
+
policy: RetryPolicy | None = None,
|
|
288
|
+
on_retry: Callable[[RetryContext], None] | None = None,
|
|
289
|
+
on_success: Callable[[T, RetryStats], None] | None = None,
|
|
290
|
+
on_failure: Callable[[Exception, RetryStats], None] | None = None,
|
|
291
|
+
**kwargs: Any,
|
|
292
|
+
) -> T:
|
|
293
|
+
"""
|
|
294
|
+
Retry an async function with exponential backoff.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
func: Async function to retry.
|
|
298
|
+
*args: Positional arguments for the function.
|
|
299
|
+
policy: Retry policy to use.
|
|
300
|
+
on_retry: Callback invoked before each retry.
|
|
301
|
+
on_success: Callback invoked on success.
|
|
302
|
+
on_failure: Callback invoked on final failure.
|
|
303
|
+
**kwargs: Keyword arguments for the function.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
Function result on success.
|
|
307
|
+
|
|
308
|
+
Raises:
|
|
309
|
+
Exception: The last exception if all retries fail.
|
|
310
|
+
|
|
311
|
+
Example:
|
|
312
|
+
>>> result = await retry_async(
|
|
313
|
+
... fetch_data,
|
|
314
|
+
... url="https://api.example.com",
|
|
315
|
+
... policy=RetryPolicy(max_attempts=3),
|
|
316
|
+
... )
|
|
317
|
+
"""
|
|
318
|
+
effective_policy = policy or DEFAULT_RETRY_POLICY
|
|
319
|
+
stats = RetryStats()
|
|
320
|
+
started_at = datetime.now(timezone.utc)
|
|
321
|
+
last_exception: Exception | None = None
|
|
322
|
+
total_delay = 0.0
|
|
323
|
+
|
|
324
|
+
for attempt in range(1, effective_policy.max_attempts + 1):
|
|
325
|
+
try:
|
|
326
|
+
result = await func(*args, **kwargs)
|
|
327
|
+
stats.record_attempt(success=True)
|
|
328
|
+
|
|
329
|
+
if on_success:
|
|
330
|
+
on_success(result, stats)
|
|
331
|
+
|
|
332
|
+
logger.debug(
|
|
333
|
+
f"Retry succeeded on attempt {attempt}/{effective_policy.max_attempts}"
|
|
334
|
+
)
|
|
335
|
+
return result
|
|
336
|
+
|
|
337
|
+
except Exception as e:
|
|
338
|
+
last_exception = e
|
|
339
|
+
should_retry = effective_policy.should_retry(e, attempt)
|
|
340
|
+
|
|
341
|
+
stats.record_attempt(success=False, exception=e)
|
|
342
|
+
|
|
343
|
+
if should_retry:
|
|
344
|
+
delay = effective_policy.calculate_delay(attempt)
|
|
345
|
+
total_delay += delay
|
|
346
|
+
|
|
347
|
+
context = RetryContext(
|
|
348
|
+
attempt=attempt,
|
|
349
|
+
total_delay=total_delay,
|
|
350
|
+
last_exception=e,
|
|
351
|
+
should_retry=True,
|
|
352
|
+
started_at=started_at,
|
|
353
|
+
policy=effective_policy,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
if on_retry:
|
|
357
|
+
on_retry(context)
|
|
358
|
+
|
|
359
|
+
logger.warning(
|
|
360
|
+
f"Attempt {attempt}/{effective_policy.max_attempts} failed: {e}. "
|
|
361
|
+
f"Retrying in {delay:.2f}s"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
await asyncio.sleep(delay)
|
|
365
|
+
else:
|
|
366
|
+
break
|
|
367
|
+
|
|
368
|
+
# All retries exhausted
|
|
369
|
+
if on_failure and last_exception:
|
|
370
|
+
on_failure(last_exception, stats)
|
|
371
|
+
|
|
372
|
+
logger.error(
|
|
373
|
+
f"All {effective_policy.max_attempts} attempts failed. "
|
|
374
|
+
f"Last error: {last_exception}"
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
if last_exception:
|
|
378
|
+
raise last_exception
|
|
379
|
+
raise RuntimeError("Unexpected state: no exception but all retries failed")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def retry_sync(
|
|
383
|
+
func: Callable[..., T],
|
|
384
|
+
*args: Any,
|
|
385
|
+
policy: RetryPolicy | None = None,
|
|
386
|
+
on_retry: Callable[[RetryContext], None] | None = None,
|
|
387
|
+
on_success: Callable[[T, RetryStats], None] | None = None,
|
|
388
|
+
on_failure: Callable[[Exception, RetryStats], None] | None = None,
|
|
389
|
+
**kwargs: Any,
|
|
390
|
+
) -> T:
|
|
391
|
+
"""
|
|
392
|
+
Retry a sync function with exponential backoff.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
func: Function to retry.
|
|
396
|
+
*args: Positional arguments for the function.
|
|
397
|
+
policy: Retry policy to use.
|
|
398
|
+
on_retry: Callback invoked before each retry.
|
|
399
|
+
on_success: Callback invoked on success.
|
|
400
|
+
on_failure: Callback invoked on final failure.
|
|
401
|
+
**kwargs: Keyword arguments for the function.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
Function result on success.
|
|
405
|
+
|
|
406
|
+
Raises:
|
|
407
|
+
Exception: The last exception if all retries fail.
|
|
408
|
+
|
|
409
|
+
Example:
|
|
410
|
+
>>> result = retry_sync(
|
|
411
|
+
... requests.get,
|
|
412
|
+
... "https://api.example.com",
|
|
413
|
+
... policy=RetryPolicy(max_attempts=3),
|
|
414
|
+
... )
|
|
415
|
+
"""
|
|
416
|
+
effective_policy = policy or DEFAULT_RETRY_POLICY
|
|
417
|
+
stats = RetryStats()
|
|
418
|
+
started_at = datetime.now(timezone.utc)
|
|
419
|
+
last_exception: Exception | None = None
|
|
420
|
+
total_delay = 0.0
|
|
421
|
+
|
|
422
|
+
for attempt in range(1, effective_policy.max_attempts + 1):
|
|
423
|
+
try:
|
|
424
|
+
result = func(*args, **kwargs)
|
|
425
|
+
stats.record_attempt(success=True)
|
|
426
|
+
|
|
427
|
+
if on_success:
|
|
428
|
+
on_success(result, stats)
|
|
429
|
+
|
|
430
|
+
logger.debug(
|
|
431
|
+
f"Retry succeeded on attempt {attempt}/{effective_policy.max_attempts}"
|
|
432
|
+
)
|
|
433
|
+
return result
|
|
434
|
+
|
|
435
|
+
except Exception as e:
|
|
436
|
+
last_exception = e
|
|
437
|
+
should_retry = effective_policy.should_retry(e, attempt)
|
|
438
|
+
|
|
439
|
+
stats.record_attempt(success=False, exception=e)
|
|
440
|
+
|
|
441
|
+
if should_retry:
|
|
442
|
+
delay = effective_policy.calculate_delay(attempt)
|
|
443
|
+
total_delay += delay
|
|
444
|
+
|
|
445
|
+
context = RetryContext(
|
|
446
|
+
attempt=attempt,
|
|
447
|
+
total_delay=total_delay,
|
|
448
|
+
last_exception=e,
|
|
449
|
+
should_retry=True,
|
|
450
|
+
started_at=started_at,
|
|
451
|
+
policy=effective_policy,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
if on_retry:
|
|
455
|
+
on_retry(context)
|
|
456
|
+
|
|
457
|
+
logger.warning(
|
|
458
|
+
f"Attempt {attempt}/{effective_policy.max_attempts} failed: {e}. "
|
|
459
|
+
f"Retrying in {delay:.2f}s"
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
time.sleep(delay)
|
|
463
|
+
else:
|
|
464
|
+
break
|
|
465
|
+
|
|
466
|
+
# All retries exhausted
|
|
467
|
+
if on_failure and last_exception:
|
|
468
|
+
on_failure(last_exception, stats)
|
|
469
|
+
|
|
470
|
+
logger.error(
|
|
471
|
+
f"All {effective_policy.max_attempts} attempts failed. "
|
|
472
|
+
f"Last error: {last_exception}"
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
if last_exception:
|
|
476
|
+
raise last_exception
|
|
477
|
+
raise RuntimeError("Unexpected state: no exception but all retries failed")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class RetryBudget:
|
|
481
|
+
"""
|
|
482
|
+
A budget-based retry limiter to prevent retry storms.
|
|
483
|
+
|
|
484
|
+
Tracks retry attempts across multiple operations and limits
|
|
485
|
+
the total retry rate to prevent cascading failures.
|
|
486
|
+
|
|
487
|
+
Attributes:
|
|
488
|
+
max_retries_per_second: Maximum retries allowed per second.
|
|
489
|
+
window_seconds: Time window for tracking.
|
|
490
|
+
|
|
491
|
+
Example:
|
|
492
|
+
>>> budget = RetryBudget(max_retries_per_second=10)
|
|
493
|
+
>>> if budget.allow_retry():
|
|
494
|
+
... # Proceed with retry
|
|
495
|
+
... pass
|
|
496
|
+
"""
|
|
497
|
+
|
|
498
|
+
def __init__(
|
|
499
|
+
self,
|
|
500
|
+
max_retries_per_second: float = 10.0,
|
|
501
|
+
window_seconds: float = 1.0,
|
|
502
|
+
) -> None:
|
|
503
|
+
"""
|
|
504
|
+
Initialize the retry budget.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
max_retries_per_second: Maximum retries per second.
|
|
508
|
+
window_seconds: Time window for tracking.
|
|
509
|
+
"""
|
|
510
|
+
self.max_retries_per_second = max_retries_per_second
|
|
511
|
+
self.window_seconds = window_seconds
|
|
512
|
+
self._tokens = max_retries_per_second
|
|
513
|
+
self._last_update = time.monotonic()
|
|
514
|
+
self._lock = threading.Lock()
|
|
515
|
+
|
|
516
|
+
def allow_retry(self) -> bool:
|
|
517
|
+
"""
|
|
518
|
+
Check if a retry is allowed within budget.
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
True if retry is allowed, False otherwise.
|
|
522
|
+
"""
|
|
523
|
+
with self._lock:
|
|
524
|
+
now = time.monotonic()
|
|
525
|
+
elapsed = now - self._last_update
|
|
526
|
+
self._last_update = now
|
|
527
|
+
|
|
528
|
+
# Refill tokens
|
|
529
|
+
self._tokens = min(
|
|
530
|
+
self.max_retries_per_second,
|
|
531
|
+
self._tokens + elapsed * self.max_retries_per_second,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
if self._tokens >= 1.0:
|
|
535
|
+
self._tokens -= 1.0
|
|
536
|
+
return True
|
|
537
|
+
return False
|
|
538
|
+
|
|
539
|
+
def reset(self) -> None:
|
|
540
|
+
"""Reset the budget to full capacity."""
|
|
541
|
+
with self._lock:
|
|
542
|
+
self._tokens = self.max_retries_per_second
|
|
543
|
+
self._last_update = time.monotonic()
|
|
544
|
+
|
|
545
|
+
@property
|
|
546
|
+
def available_tokens(self) -> float:
|
|
547
|
+
"""Get current available tokens."""
|
|
548
|
+
with self._lock:
|
|
549
|
+
now = time.monotonic()
|
|
550
|
+
elapsed = now - self._last_update
|
|
551
|
+
return min(
|
|
552
|
+
self.max_retries_per_second,
|
|
553
|
+
self._tokens + elapsed * self.max_retries_per_second,
|
|
554
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Request scheduling and queuing for Proxilion.
|
|
3
|
+
|
|
4
|
+
This module provides priority-based request queuing to ensure
|
|
5
|
+
high-priority requests are processed first while preventing
|
|
6
|
+
starvation of lower-priority requests through aging.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
>>> from proxilion.scheduling import (
|
|
10
|
+
... PriorityLevel, QueuedRequest, PriorityQueue,
|
|
11
|
+
... RequestScheduler, SchedulerConfig,
|
|
12
|
+
... )
|
|
13
|
+
>>>
|
|
14
|
+
>>> # Create a priority queue
|
|
15
|
+
>>> queue = PriorityQueue(max_size=1000)
|
|
16
|
+
>>>
|
|
17
|
+
>>> # Enqueue requests with different priorities
|
|
18
|
+
>>> queue.enqueue(QueuedRequest(
|
|
19
|
+
... id="req-1",
|
|
20
|
+
... priority=PriorityLevel.HIGH,
|
|
21
|
+
... payload={"task": "urgent"},
|
|
22
|
+
... ))
|
|
23
|
+
>>> queue.enqueue(QueuedRequest(
|
|
24
|
+
... id="req-2",
|
|
25
|
+
... priority=PriorityLevel.LOW,
|
|
26
|
+
... payload={"task": "background"},
|
|
27
|
+
... ))
|
|
28
|
+
>>>
|
|
29
|
+
>>> # High priority dequeued first
|
|
30
|
+
>>> request = queue.dequeue()
|
|
31
|
+
>>> assert request.priority == PriorityLevel.HIGH
|
|
32
|
+
>>>
|
|
33
|
+
>>> # Use scheduler for concurrent processing
|
|
34
|
+
>>> config = SchedulerConfig(max_concurrent=4)
|
|
35
|
+
>>> scheduler = RequestScheduler(config)
|
|
36
|
+
>>> future = scheduler.submit(request)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from proxilion.scheduling.priority_queue import (
|
|
40
|
+
PriorityLevel,
|
|
41
|
+
PriorityQueue,
|
|
42
|
+
QueuedRequest,
|
|
43
|
+
)
|
|
44
|
+
from proxilion.scheduling.scheduler import (
|
|
45
|
+
RequestScheduler,
|
|
46
|
+
SchedulerConfig,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
# Priority queue
|
|
51
|
+
"PriorityLevel",
|
|
52
|
+
"QueuedRequest",
|
|
53
|
+
"PriorityQueue",
|
|
54
|
+
# Scheduler
|
|
55
|
+
"RequestScheduler",
|
|
56
|
+
"SchedulerConfig",
|
|
57
|
+
]
|