requestflow 0.1.0__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.
@@ -0,0 +1,15 @@
1
+ from requestflow.deduplication import InFlightDeduplicator, ResultCache
2
+ from requestflow.exceptions import IntegrationError, OperationCancelledError, QueueFullError, RateLimitExceededError, RequestFlowError, RequestTimeoutError, SchedulerClosedError, SchedulerNotStartedError, UnsupportedDiscordVersionError
3
+ from requestflow.executor import RequestExecutor
4
+ from requestflow.flow import RequestFlow
5
+ from requestflow.integrations.discord_py import DiscordHTTPAdapter, attach_to_bot
6
+ from requestflow.metrics import RequestMetrics
7
+ from requestflow.models import MetricsSnapshot, RequestItem
8
+ from requestflow.policies import OverloadPolicy, PacingStrategy, RetryPolicy
9
+ from requestflow.priorities import Priority
10
+ from requestflow.queue import RequestQueue
11
+ from requestflow.ratelimit import GlobalRateLimiter, RateLimitCoordinator, RouteBucketCoordinator
12
+ from requestflow.scheduler import RequestScheduler
13
+ __version__ = '0.1.0'
14
+ __all__ = ['__version__', 'requestflow', 'priority', 'overloadpolicy', 'pacingstrategy', 'retrypolicy', 'requestmetrics', 'metricssnapshot', 'requestitem', 'requestscheduler', 'requestqueue', 'requestexecutor', 'ratelimitcoordinator', 'globalratelimiter', 'routebucketcoordinator',
15
+ 'inflightdeduplicator', 'resultcache', 'discordhttpadapter', 'attach_to_bot', 'requestflowerror', 'queuefullerror', 'schedulerclosederror', 'schedulernotstartederror', 'requesttimeouterror', 'ratelimitexceedederror', 'integrationerror', 'unsupporteddiscordversionerror', 'operationcancellederror']
@@ -0,0 +1,104 @@
1
+ import asyncio
2
+ import collections
3
+ import time
4
+ from typing import Any, Callable, Coroutine, Dict, Generic, List, Optional, Tuple, TypeVar
5
+ T = TypeVar('t')
6
+
7
+
8
+ class InFlightDeduplicator:
9
+ def __init__(self) -> None:
10
+ self._waiters: Dict[str, List[asyncio.Future]] = {}
11
+ self._tasks: Dict[str, asyncio.Task] = {}
12
+ self._lock: asyncio.Lock = asyncio.Lock()
13
+
14
+ async def execute_or_join(self, dedup_key: str, runner: Callable[[], Coroutine[Any, Any, T]]) -> T:
15
+ loop = asyncio.get_running_loop()
16
+ caller_future: asyncio.Future = loop.create_future()
17
+ async with self._lock:
18
+ if dedup_key in self._waiters:
19
+ self._waiters[dedup_key].append(caller_future)
20
+ is_leader = False
21
+ else:
22
+ self._waiters[dedup_key] = [caller_future]
23
+ is_leader = True
24
+ task = asyncio.create_task(self._run_leader(dedup_key, runner))
25
+ self._tasks[dedup_key] = task
26
+ try:
27
+ return await caller_future
28
+ except asyncio.CancelledError:
29
+ async with self._lock:
30
+ if dedup_key in self._waiters:
31
+ waiters = self._waiters[dedup_key]
32
+ if caller_future in waiters:
33
+ waiters.remove(caller_future)
34
+ if len(waiters) == 0 and dedup_key in self._tasks:
35
+ task = self._tasks.pop(dedup_key, None)
36
+ if task and (not task.done()):
37
+ task.cancel()
38
+ self._waiters.pop(dedup_key, None)
39
+ raise
40
+
41
+ async def _run_leader(self, dedup_key: str, runner: Callable[[], Coroutine[Any, Any, T]]) -> None:
42
+ result: Any = None
43
+ error: Optional[BaseException] = None
44
+ try:
45
+ result = await runner()
46
+ except asyncio.CancelledError as exc:
47
+ error = exc
48
+ except BaseException as exc:
49
+ error = exc
50
+ async with self._lock:
51
+ waiters = self._waiters.pop(dedup_key, [])
52
+ self._tasks.pop(dedup_key, None)
53
+ for fut in waiters:
54
+ if not fut.done():
55
+ if error is not None:
56
+ if isinstance(error, asyncio.CancelledError):
57
+ fut.cancel()
58
+ else:
59
+ fut.set_exception(error)
60
+ else:
61
+ fut.set_result(result)
62
+
63
+
64
+ class ResultCache(Generic[T]):
65
+ def __init__(self, max_capacity: int = 1000, default_ttl_seconds: float = 60.0) -> None:
66
+ if max_capacity <= 0:
67
+ raise ValueError('max_capacity must be > 0')
68
+ self._max_capacity: int = max_capacity
69
+ self._default_ttl: float = default_ttl_seconds
70
+ self._entries: collections.OrderedDict[str,
71
+ Tuple[T, float]] = collections.OrderedDict()
72
+ self._lock: asyncio.Lock = asyncio.Lock()
73
+
74
+ async def get(self, key: str) -> Optional[T]:
75
+ async with self._lock:
76
+ if key not in self._entries:
77
+ return None
78
+ val, expiry = self._entries[key]
79
+ if time.monotonic() > expiry:
80
+ del self._entries[key]
81
+ return None
82
+ self._entries.move_to_end(key)
83
+ return val
84
+
85
+ async def set(self, key: str, value: T, ttl_seconds: Optional[float] = None) -> None:
86
+ ttl = ttl_seconds if ttl_seconds is not None else self._default_ttl
87
+ expiry = time.monotonic() + ttl
88
+ async with self._lock:
89
+ if key in self._entries:
90
+ del self._entries[key]
91
+ elif len(self._entries) >= self._max_capacity:
92
+ self._entries.popitem(last=False)
93
+ self._entries[key] = (value, expiry)
94
+
95
+ async def invalidate(self, key: str) -> bool:
96
+ async with self._lock:
97
+ if key in self._entries:
98
+ del self._entries[key]
99
+ return True
100
+ return False
101
+
102
+ async def clear(self) -> None:
103
+ async with self._lock:
104
+ self._entries.clear()
@@ -0,0 +1,43 @@
1
+ from typing import Optional
2
+
3
+
4
+ class RequestFlowError(Exception):
5
+ pass
6
+
7
+
8
+ class QueueFullError(RequestFlowError):
9
+ pass
10
+
11
+
12
+ class SchedulerClosedError(RequestFlowError):
13
+ pass
14
+
15
+
16
+ class SchedulerNotStartedError(RequestFlowError):
17
+ pass
18
+
19
+
20
+ class RequestTimeoutError(RequestFlowError):
21
+ def __init__(self, message: str, *, timeout_seconds: float) -> None:
22
+ super().__init__(message)
23
+ self.timeout_seconds: float = timeout_seconds
24
+
25
+
26
+ class RateLimitExceededError(RequestFlowError):
27
+ def __init__(self, message: str, *, retry_after: Optional[float] = None) -> None:
28
+ super().__init__(message)
29
+ self.retry_after: Optional[float] = retry_after
30
+
31
+
32
+ class IntegrationError(RequestFlowError):
33
+ pass
34
+
35
+
36
+ class UnsupportedDiscordVersionError(IntegrationError):
37
+ def __init__(self, message: str, *, detected_version: Optional[str] = None) -> None:
38
+ super().__init__(message)
39
+ self.detected_version: Optional[str] = detected_version
40
+
41
+
42
+ class OperationCancelledError(RequestFlowError):
43
+ pass
@@ -0,0 +1,89 @@
1
+ import asyncio
2
+ import inspect
3
+ import time
4
+ from typing import Any, Callable, Coroutine, Optional
5
+ from requestflow.exceptions import OperationCancelledError, RequestTimeoutError
6
+ from requestflow.models import RequestItem
7
+ from requestflow.policies import RetryPolicy
8
+
9
+
10
+ class RequestExecutor:
11
+ def __init__(self, max_concurrency: int = 16, default_timeout: Optional[float] = None, default_retry_policy: Optional[RetryPolicy] = None) -> None:
12
+ if max_concurrency <= 0:
13
+ raise ValueError(
14
+ f'max_concurrency must be > 0, got {max_concurrency}')
15
+ self._max_concurrency: int = max_concurrency
16
+ self._semaphore: asyncio.Semaphore = asyncio.Semaphore(max_concurrency)
17
+ self._default_timeout: Optional[float] = default_timeout
18
+ self._default_retry_policy: Optional[RetryPolicy] = default_retry_policy
19
+ self._currently_executing: int = 0
20
+ self._lock: asyncio.Lock = asyncio.Lock()
21
+
22
+ @property
23
+ def max_concurrency(self) -> int:
24
+ return self._max_concurrency
25
+
26
+ @property
27
+ def currently_executing(self) -> int:
28
+ return self._currently_executing
29
+
30
+ async def execute(self, item: RequestItem, on_complete: Optional[Callable[[RequestItem, Optional[BaseException]], None]] = None) -> None:
31
+ if item.cancelled or (item.future and item.future.cancelled()):
32
+ if on_complete:
33
+ on_complete(item, OperationCancelledError(
34
+ 'operation cancelled before execution'))
35
+ return
36
+ async with self._semaphore:
37
+ async with self._lock:
38
+ self._currently_executing += 1
39
+ item.execution_start_time = time.monotonic()
40
+ error: Optional[BaseException] = None
41
+ retry_policy = self._default_retry_policy
42
+ timeout = item.timeout if item.timeout is not None else self._default_timeout
43
+ attempts = 0
44
+ try:
45
+ while True:
46
+ attempts += 1
47
+ try:
48
+ res = item.operation(*item.args, **item.kwargs)
49
+ if inspect.isawaitable(res):
50
+ if timeout is not None and timeout > 0:
51
+ result = await asyncio.wait_for(res, timeout=timeout)
52
+ else:
53
+ result = await res
54
+ else:
55
+ result = res
56
+ item.completion_time = time.monotonic()
57
+ if item.future and (not item.future.done()):
58
+ item.future.set_result(result)
59
+ break
60
+ except asyncio.TimeoutError as exc:
61
+ timeout_err = RequestTimeoutError(
62
+ f'operation timed out after {timeout:.2f} seconds', timeout_seconds=float(timeout or 0.0))
63
+ error = timeout_err
64
+ item.completion_time = time.monotonic()
65
+ if item.future and (not item.future.done()):
66
+ item.future.set_exception(timeout_err)
67
+ break
68
+ except asyncio.CancelledError as exc:
69
+ item.cancelled = True
70
+ error = exc
71
+ item.completion_time = time.monotonic()
72
+ if item.future and (not item.future.done()):
73
+ item.future.cancel()
74
+ raise
75
+ except BaseException as exc:
76
+ error = exc
77
+ if retry_policy and retry_policy.is_retryable(exc) and (attempts <= retry_policy.max_retries):
78
+ delay = retry_policy.compute_delay(attempts - 1)
79
+ await asyncio.sleep(delay)
80
+ continue
81
+ item.completion_time = time.monotonic()
82
+ if item.future and (not item.future.done()):
83
+ item.future.set_exception(exc)
84
+ break
85
+ finally:
86
+ async with self._lock:
87
+ self._currently_executing -= 1
88
+ if on_complete:
89
+ on_complete(item, error)
requestflow/flow.py ADDED
@@ -0,0 +1,73 @@
1
+ import asyncio
2
+ from typing import Any, Callable, Coroutine, Optional, TypeVar, Union
3
+ from requestflow.deduplication import InFlightDeduplicator, ResultCache
4
+ from requestflow.exceptions import RequestFlowError
5
+ from requestflow.integrations.discord_py import DiscordHTTPAdapter
6
+ from requestflow.models import MetricsSnapshot
7
+ from requestflow.policies import OverloadPolicy, RetryPolicy
8
+ from requestflow.priorities import Priority
9
+ from requestflow.scheduler import RequestScheduler
10
+ T = TypeVar('T')
11
+
12
+
13
+ class RequestFlow:
14
+ def __init__(self, bot: Optional[Any] = None, *, max_pending: int = 5000, max_concurrency: int = 16, global_rate_limit: float = 40.0, rate_limit_ceiling: float = 50.0, burst_allowance: float = 5.0, allow_custom_ceiling: bool = False, overload_policy: Union[OverloadPolicy, str] = OverloadPolicy.REJECT, aging_threshold_seconds: float = 2.0, guild_quantum: int = 3, default_timeout: Optional[float] = None, default_retry_policy: Optional[RetryPolicy] = None, auto_attach: bool = True) -> None:
15
+ if isinstance(overload_policy, str):
16
+ overload_policy = OverloadPolicy(overload_policy.lower())
17
+ self.scheduler = RequestScheduler(max_pending=max_pending, max_concurrency=max_concurrency, global_rate_limit=global_rate_limit, rate_limit_ceiling=rate_limit_ceiling, burst_allowance=burst_allowance,
18
+ allow_custom_ceiling=allow_custom_ceiling, overload_policy=overload_policy, aging_threshold_seconds=aging_threshold_seconds, guild_quantum=guild_quantum, default_timeout=default_timeout, default_retry_policy=default_retry_policy)
19
+ self._bot: Optional[Any] = bot
20
+ self._auto_attach: bool = auto_attach
21
+ self._adapter: DiscordHTTPAdapter = DiscordHTTPAdapter(self.scheduler)
22
+ self.deduplicator: InFlightDeduplicator = InFlightDeduplicator()
23
+ self.cache: ResultCache = ResultCache()
24
+
25
+ @property
26
+ def metrics(self):
27
+ return self.scheduler.metrics
28
+
29
+ @property
30
+ def stats(self) -> MetricsSnapshot:
31
+ return self.scheduler.metrics.snapshot()
32
+
33
+ @property
34
+ def is_started(self) -> bool:
35
+ return self.scheduler.is_started
36
+
37
+ @property
38
+ def is_closed(self) -> bool:
39
+ return self.scheduler.is_closed
40
+
41
+ async def start(self) -> 'RequestFlow':
42
+ await self.scheduler.start()
43
+ if self._bot is not None and self._auto_attach:
44
+ self.attach(self._bot)
45
+ return self
46
+
47
+ async def close(self, drain: bool = True, timeout: float = 5.0) -> None:
48
+ self._adapter.detach_all()
49
+ await self.scheduler.close(drain=drain, timeout=timeout)
50
+
51
+ async def __aenter__(self) -> 'RequestFlow':
52
+ await self.start()
53
+ return self
54
+
55
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
56
+ await self.close(drain=True)
57
+
58
+ def attach(self, bot: Any) -> None:
59
+ self._adapter.attach(bot)
60
+
61
+ def detach(self, bot: Any) -> None:
62
+ self._adapter.detach(bot)
63
+
64
+ async def update_rate(self, new_rate: float) -> None:
65
+ await self.scheduler.rate_coordinator.global_limiter.update_rate(new_rate)
66
+
67
+ async def submit(self, operation: Callable[..., Coroutine[Any, Any, T]], *args: Any, priority: Union[Priority, str, int] = Priority.NORMAL, guild_id: Optional[int] = None, route_key: Optional[str] = None, is_interaction: bool = False, dedup_key: Optional[str] = None, timeout: Optional[float] = None, enqueue_timeout: Optional[float] = None, **kwargs: Any) -> T:
68
+ parsed_priority = Priority.from_value(priority)
69
+ if dedup_key is not None:
70
+ async def run_single() -> T:
71
+ return await self.scheduler.submit(operation, *args, priority=parsed_priority, guild_id=guild_id, route_key=route_key, is_interaction=is_interaction, dedup_key=dedup_key, timeout=timeout, enqueue_timeout=enqueue_timeout, **kwargs)
72
+ return await self.deduplicator.execute_or_join(dedup_key, run_single)
73
+ return await self.scheduler.submit(operation, *args, priority=parsed_priority, guild_id=guild_id, route_key=route_key, is_interaction=is_interaction, dedup_key=dedup_key, timeout=timeout, enqueue_timeout=enqueue_timeout, **kwargs)
@@ -0,0 +1,2 @@
1
+ from requestflow.integrations.discord_py import DiscordHTTPAdapter, attach_to_bot
2
+ __all__ = ['discordhttpadapter', 'attach_to_bot']
@@ -0,0 +1,115 @@
1
+ import functools
2
+ import inspect
3
+ from typing import Any, Callable, Optional, Sequence, Union
4
+ try:
5
+ import discord
6
+ import discord.http
7
+ HAS_DISCORD = True
8
+ except ImportError:
9
+ HAS_DISCORD = False
10
+ from requestflow.exceptions import IntegrationError, UnsupportedDiscordVersionError
11
+ from requestflow.priorities import Priority
12
+ from requestflow.scheduler import RequestScheduler
13
+
14
+
15
+ class DiscordHTTPAdapter:
16
+ MIN_SUPPORTED_DISCORD_VERSION = (2, 0, 0)
17
+
18
+ def __init__(self, scheduler: RequestScheduler) -> None:
19
+ self.scheduler: RequestScheduler = scheduler
20
+ self._attached_clients: set = set()
21
+ self._original_requests: dict = {}
22
+
23
+ @classmethod
24
+ def verify_compatibility(cls) -> None:
25
+ if not HAS_DISCORD:
26
+ raise IntegrationError(
27
+ 'discord.py is not installed in the current environment.')
28
+ version_str = getattr(discord, '__version__', 'unknown')
29
+ try:
30
+ parts = version_str.split('.')
31
+ major = int(parts[0])
32
+ minor = int(parts[1].rstrip('abcdefghijklmnopqrstuvwxyz'))
33
+ if major < 2:
34
+ raise UnsupportedDiscordVersionError(
35
+ f'requestflow requires discord.py >= 2.0.0, but found {version_str}.', detected_version=version_str)
36
+ except (ValueError, IndexError):
37
+ if not hasattr(discord.http, 'httpclient') or not hasattr(discord.http, 'route'):
38
+ raise UnsupportedDiscordVersionError(
39
+ f'unsupported discord.py layout or version: {version_str}.', detected_version=version_str)
40
+
41
+ def attach(self, client: Any) -> None:
42
+ self.verify_compatibility()
43
+ http_client = getattr(client, 'http', None)
44
+ if http_client is None:
45
+ if isinstance(client, getattr(discord.http, 'httpclient', object)):
46
+ http_client = client
47
+ else:
48
+ raise IntegrationError(
49
+ f"target object {type(client).__name__} does not have an 'http' attribute.")
50
+ if not hasattr(http_client, 'request') or not callable(http_client.request):
51
+ raise IntegrationError(
52
+ f"http client {type(http_client).__name__} has no callable 'request' method.")
53
+ if http_client in self._attached_clients:
54
+ return
55
+ original_request = http_client.request
56
+ self._original_requests[http_client] = original_request
57
+
58
+ @functools.wraps(original_request)
59
+ async def scheduled_request(route: Any, *args: Any, **kwargs: Any) -> Any:
60
+ guild_id = getattr(route, 'guild_id', None)
61
+ route_path = getattr(route, 'path', '')
62
+ route_key = getattr(
63
+ route, 'key', f"{getattr(route, 'method', 'GET')} {route_path}")
64
+ is_interaction = bool(
65
+ route_path and '/interactions/' in route_path)
66
+ if is_interaction:
67
+ priority = Priority.CRITICAL
68
+ elif getattr(route, 'method', '') == 'DELETE':
69
+ priority = Priority.HIGH
70
+ else:
71
+ priority = Priority.NORMAL
72
+
73
+ async def run_http() -> Any:
74
+ try:
75
+ return await original_request(route, *args, **kwargs)
76
+ except getattr(discord, 'ratelimited', Exception) as exc:
77
+ retry_after = getattr(exc, 'retry_after', 0.0)
78
+ if retry_after > 0:
79
+ self.scheduler.rate_coordinator.handle_429(
80
+ retry_after, is_global=False, route_key=route_key)
81
+ self.scheduler.metrics.record_429(is_global=False)
82
+ raise
83
+ except getattr(discord, 'httpexception', Exception) as exc:
84
+ if getattr(exc, 'status', None) == 429:
85
+ retry_after = 1.0
86
+ if hasattr(exc, 'response') and hasattr(exc.response, 'headers'):
87
+ retry_after = float(
88
+ exc.response.headers.get('Retry-After', 1.0))
89
+ self.scheduler.rate_coordinator.handle_429(
90
+ retry_after, is_global=False, route_key=route_key)
91
+ self.scheduler.metrics.record_429(is_global=False)
92
+ raise
93
+ return await self.scheduler.submit(run_http, priority=priority, guild_id=guild_id, route_key=route_key, is_interaction=is_interaction)
94
+ http_client.request = scheduled_request
95
+ self._attached_clients.add(http_client)
96
+
97
+ def detach(self, client: Any) -> None:
98
+ http_client = getattr(client, 'http', client)
99
+ if http_client in self._attached_clients:
100
+ original = self._original_requests.pop(http_client, None)
101
+ if original:
102
+ http_client.request = original
103
+ self._attached_clients.remove(http_client)
104
+
105
+ def detach_all(self) -> None:
106
+ for http_client, original in list(self._original_requests.items()):
107
+ http_client.request = original
108
+ self._attached_clients.clear()
109
+ self._original_requests.clear()
110
+
111
+
112
+ def attach_to_bot(scheduler: RequestScheduler, bot: Any) -> DiscordHTTPAdapter:
113
+ adapter = DiscordHTTPAdapter(scheduler)
114
+ adapter.attach(bot)
115
+ return adapter
requestflow/metrics.py ADDED
@@ -0,0 +1,104 @@
1
+ import collections
2
+ import time
3
+ from typing import Callable, Deque, Dict, List, Optional
4
+ from requestflow.models import MetricsSnapshot
5
+
6
+
7
+ class RequestMetrics:
8
+ def __init__(self, sample_buffer_size: int = 1000, throughput_window_seconds: float = 10.0) -> None:
9
+ self._buffer_size: int = sample_buffer_size
10
+ self._window_seconds: float = throughput_window_seconds
11
+ self.submitted: int = 0
12
+ self.completed: int = 0
13
+ self.failed: int = 0
14
+ self.cancelled: int = 0
15
+ self.rejected: int = 0
16
+ self.rate_limit_429_count: int = 0
17
+ self.cooldown_count_global: int = 0
18
+ self.cooldown_count_route: int = 0
19
+ self.retries_attempted: int = 0
20
+ self.total_rate_limit_wait_time: float = 0.0
21
+ self._queue_wait_samples: Deque[float] = collections.deque(
22
+ maxlen=sample_buffer_size)
23
+ self._exec_duration_samples: Deque[float] = collections.deque(
24
+ maxlen=sample_buffer_size)
25
+ self._completion_timestamps: Deque[float] = collections.deque()
26
+ self._get_currently_queued: Callable[[], int] = lambda: 0
27
+ self._get_currently_executing: Callable[[], int] = lambda: 0
28
+ self._get_high_water_mark: Callable[[], int] = lambda: 0
29
+ self._get_per_priority: Callable[[], Dict[str, int]] = lambda: {}
30
+ self._get_active_guilds: Callable[[], int] = lambda: 0
31
+
32
+ def register_providers(self, get_currently_queued: Callable[[], int], get_currently_executing: Callable[[], int], get_high_water_mark: Callable[[], int], get_per_priority: Callable[[], Dict[str, int]], get_active_guilds: Callable[[], int]) -> None:
33
+ self._get_currently_queued = get_currently_queued
34
+ self._get_currently_executing = get_currently_executing
35
+ self._get_high_water_mark = get_high_water_mark
36
+ self._get_per_priority = get_per_priority
37
+ self._get_active_guilds = get_active_guilds
38
+
39
+ def record_submit(self) -> None:
40
+ self.submitted += 1
41
+
42
+ def record_reject(self) -> None:
43
+ self.rejected += 1
44
+
45
+ def record_cancel(self) -> None:
46
+ self.cancelled += 1
47
+
48
+ def record_retry(self) -> None:
49
+ self.retries_attempted += 1
50
+
51
+ def record_rate_limit_wait(self, duration_seconds: float) -> None:
52
+ if duration_seconds > 0:
53
+ self.total_rate_limit_wait_time += duration_seconds
54
+
55
+ def record_429(self, *, is_global: bool = False) -> None:
56
+ self.rate_limit_429_count += 1
57
+ if is_global:
58
+ self.cooldown_count_global += 1
59
+ else:
60
+ self.cooldown_count_route += 1
61
+
62
+ def record_completion(self, queue_wait_seconds: float, exec_duration_seconds: float, error: Optional[BaseException] = None) -> None:
63
+ now = time.monotonic()
64
+ if error is not None:
65
+ self.failed += 1
66
+ else:
67
+ self.completed += 1
68
+ self._queue_wait_samples.append(queue_wait_seconds * 1000.0)
69
+ self._exec_duration_samples.append(exec_duration_seconds * 1000.0)
70
+ self._completion_timestamps.append(now)
71
+ self._prune_throughput_window(now)
72
+
73
+ def _prune_throughput_window(self, now: float) -> None:
74
+ cutoff = now - self._window_seconds
75
+ while self._completion_timestamps and self._completion_timestamps[0] < cutoff:
76
+ self._completion_timestamps.popleft()
77
+
78
+ def get_throughput_rps(self) -> float:
79
+ now = time.monotonic()
80
+ self._prune_throughput_window(now)
81
+ count = len(self._completion_timestamps)
82
+ if count == 0:
83
+ return 0.0
84
+ return count / self._window_seconds
85
+
86
+ @staticmethod
87
+ def _calculate_percentile(samples: List[float], percentile: float) -> float:
88
+ if not samples:
89
+ return 0.0
90
+ idx = int(len(samples) * (percentile / 100.0))
91
+ idx = min(idx, len(samples) - 1)
92
+ return samples[idx]
93
+
94
+ def snapshot(self) -> MetricsSnapshot:
95
+ now = time.monotonic()
96
+ sorted_wait = sorted(self._queue_wait_samples)
97
+ sorted_exec = sorted(self._exec_duration_samples)
98
+ p50_wait = self._calculate_percentile(sorted_wait, 50.0)
99
+ p95_wait = self._calculate_percentile(sorted_wait, 95.0)
100
+ p99_wait = self._calculate_percentile(sorted_wait, 99.0)
101
+ p50_exec = self._calculate_percentile(sorted_exec, 50.0)
102
+ p95_exec = self._calculate_percentile(sorted_exec, 95.0)
103
+ p99_exec = self._calculate_percentile(sorted_exec, 99.0)
104
+ return MetricsSnapshot(timestamp=now, submitted=self.submitted, completed=self.completed, failed=self.failed, cancelled=self.cancelled, rejected=self.rejected, currently_queued=self._get_currently_queued(), currently_executing=self._get_currently_executing(), queue_high_water_mark=self._get_high_water_mark(), p50_queue_wait_ms=round(p50_wait, 2), p95_queue_wait_ms=round(p95_wait, 2), p99_queue_wait_ms=round(p99_wait, 2), p50_exec_latency_ms=round(p50_exec, 2), p95_exec_latency_ms=round(p95_exec, 2), p99_exec_latency_ms=round(p99_exec, 2), throughput_window_rps=round(self.get_throughput_rps(), 2), rate_limit_429_count=self.rate_limit_429_count, cooldown_count_global=self.cooldown_count_global, cooldown_count_route=self.cooldown_count_route, retries_attempted=self.retries_attempted, active_guilds=self._get_active_guilds(), per_priority_queued=self._get_per_priority())
requestflow/models.py ADDED
@@ -0,0 +1,75 @@
1
+ import asyncio
2
+ import time
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Callable, Coroutine, Dict, Generic, Optional, TypeVar
5
+ from requestflow.priorities import Priority
6
+ T = TypeVar('t')
7
+
8
+
9
+ @dataclass
10
+ class RequestItem(Generic[T]):
11
+ sequence: int
12
+ operation: Callable[..., Coroutine[Any, Any, T]]
13
+ args: tuple = ()
14
+ kwargs: dict = field(default_factory=dict)
15
+ priority: Priority = Priority.NORMAL
16
+ effective_priority: Priority = Priority.NORMAL
17
+ guild_id: Optional[int] = None
18
+ route_key: Optional[str] = None
19
+ is_interaction: bool = False
20
+ dedup_key: Optional[str] = None
21
+ timeout: Optional[float] = None
22
+ future: Optional[asyncio.Future] = None
23
+ enqueue_time: float = 0.0
24
+
25
+ def __post_init__(self) -> None:
26
+ if self.future is None:
27
+ try:
28
+ loop = asyncio.get_running_loop()
29
+ except RuntimeError:
30
+ loop = asyncio.get_event_loop()
31
+ self.future = loop.create_future()
32
+ if self.enqueue_time == 0.0:
33
+ self.enqueue_time = time.monotonic()
34
+ execution_start_time: Optional[float] = None
35
+ completion_time: Optional[float] = None
36
+ retries_left: int = 0
37
+ cancelled: bool = False
38
+
39
+ @property
40
+ def queue_wait_time(self) -> float:
41
+ if self.execution_start_time is None:
42
+ return time.monotonic() - self.enqueue_time
43
+ return self.execution_start_time - self.enqueue_time
44
+
45
+ @property
46
+ def execution_duration(self) -> float:
47
+ if self.execution_start_time is None or self.completion_time is None:
48
+ return 0.0
49
+ return self.completion_time - self.execution_start_time
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class MetricsSnapshot:
54
+ timestamp: float
55
+ submitted: int
56
+ completed: int
57
+ failed: int
58
+ cancelled: int
59
+ rejected: int
60
+ currently_queued: int
61
+ currently_executing: int
62
+ queue_high_water_mark: int
63
+ p50_queue_wait_ms: float
64
+ p95_queue_wait_ms: float
65
+ p99_queue_wait_ms: float
66
+ p50_exec_latency_ms: float
67
+ p95_exec_latency_ms: float
68
+ p99_exec_latency_ms: float
69
+ throughput_window_rps: float
70
+ rate_limit_429_count: int
71
+ cooldown_count_global: int
72
+ cooldown_count_route: int
73
+ retries_attempted: int
74
+ active_guilds: int
75
+ per_priority_queued: Dict[str, int] = field(default_factory=dict)
@@ -0,0 +1,39 @@
1
+ import random
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+ from typing import Optional, Sequence, Tuple, Type
5
+
6
+
7
+ class OverloadPolicy(str, Enum):
8
+ REJECT = 'reject'
9
+ WAIT = 'wait'
10
+ DROP_OLDEST = 'drop_oldest'
11
+
12
+
13
+ class PacingStrategy(str, Enum):
14
+ GCRA = 'gcra'
15
+ LEAKY_BUCKET = 'leaky_bucket'
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class RetryPolicy:
20
+ max_retries: int = 0
21
+ base_delay: float = 0.5
22
+ max_delay: float = 10.0
23
+ jitter: bool = True
24
+ retryable_exceptions: Tuple[Type[BaseException], ...] = ()
25
+
26
+ def is_retryable(self, exception: BaseException) -> bool:
27
+ if not self.retryable_exceptions:
28
+ return False
29
+ return isinstance(exception, self.retryable_exceptions)
30
+
31
+ def compute_delay(self, attempt: int, retry_after: Optional[float] = None) -> float:
32
+ if retry_after is not None and retry_after > 0:
33
+ return retry_after
34
+ delay = self.base_delay * 2 ** attempt
35
+ delay = min(delay, self.max_delay)
36
+ if self.jitter:
37
+ factor = 0.5 + random.random()
38
+ delay = delay * factor
39
+ return min(delay, self.max_delay)