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.
- requestflow/__init__.py +15 -0
- requestflow/deduplication.py +104 -0
- requestflow/exceptions.py +43 -0
- requestflow/executor.py +89 -0
- requestflow/flow.py +73 -0
- requestflow/integrations/__init__.py +2 -0
- requestflow/integrations/discord_py.py +115 -0
- requestflow/metrics.py +104 -0
- requestflow/models.py +75 -0
- requestflow/policies.py +39 -0
- requestflow/priorities.py +49 -0
- requestflow/py.typed +1 -0
- requestflow/queue.py +236 -0
- requestflow/ratelimit.py +188 -0
- requestflow/scheduler.py +131 -0
- requestflow-0.1.0.dist-info/METADATA +207 -0
- requestflow-0.1.0.dist-info/RECORD +20 -0
- requestflow-0.1.0.dist-info/WHEEL +5 -0
- requestflow-0.1.0.dist-info/licenses/LICENSE +21 -0
- requestflow-0.1.0.dist-info/top_level.txt +1 -0
requestflow/__init__.py
ADDED
|
@@ -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
|
requestflow/executor.py
ADDED
|
@@ -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,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)
|
requestflow/policies.py
ADDED
|
@@ -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)
|