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
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
from typing import Union
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Priority(IntEnum):
|
|
6
|
+
CRITICAL = 0
|
|
7
|
+
HIGH = 1
|
|
8
|
+
NORMAL = 2
|
|
9
|
+
LOW = 3
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def from_value(cls, value: Union['Priority', str, int]) -> 'Priority':
|
|
13
|
+
if isinstance(value, Priority):
|
|
14
|
+
return value
|
|
15
|
+
if isinstance(value, int):
|
|
16
|
+
try:
|
|
17
|
+
return cls(value)
|
|
18
|
+
except ValueError:
|
|
19
|
+
raise ValueError(
|
|
20
|
+
f'invalid priority integer: {value}. must be 0, 1, 2, or 3.')
|
|
21
|
+
if isinstance(value, str):
|
|
22
|
+
normalized = value.strip().upper()
|
|
23
|
+
try:
|
|
24
|
+
return cls[normalized]
|
|
25
|
+
except KeyError:
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"unknown priority name: '{value}'. valid options: {[p.name.lower() for p in cls]}")
|
|
28
|
+
raise TypeError(
|
|
29
|
+
f'priority must be priority, str, or int, not {type(value).__name__}')
|
|
30
|
+
|
|
31
|
+
def __lt__(self, other: object) -> bool:
|
|
32
|
+
if isinstance(other, Priority):
|
|
33
|
+
return self.value < other.value
|
|
34
|
+
return NotImplemented
|
|
35
|
+
|
|
36
|
+
def __le__(self, other: object) -> bool:
|
|
37
|
+
if isinstance(other, Priority):
|
|
38
|
+
return self.value <= other.value
|
|
39
|
+
return NotImplemented
|
|
40
|
+
|
|
41
|
+
def __gt__(self, other: object) -> bool:
|
|
42
|
+
if isinstance(other, Priority):
|
|
43
|
+
return self.value > other.value
|
|
44
|
+
return NotImplemented
|
|
45
|
+
|
|
46
|
+
def __ge__(self, other: object) -> bool:
|
|
47
|
+
if isinstance(other, Priority):
|
|
48
|
+
return self.value >= other.value
|
|
49
|
+
return NotImplemented
|
requestflow/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
requestflow/queue.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import collections
|
|
3
|
+
import time
|
|
4
|
+
from typing import Callable, Deque, Dict, List, Optional, Set
|
|
5
|
+
from requestflow.exceptions import QueueFullError
|
|
6
|
+
from requestflow.models import RequestItem
|
|
7
|
+
from requestflow.policies import OverloadPolicy
|
|
8
|
+
from requestflow.priorities import Priority
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RequestQueue:
|
|
12
|
+
def __init__(self, max_pending: int = 5000, overload_policy: OverloadPolicy = OverloadPolicy.REJECT, aging_threshold_seconds: float = 2.0, guild_quantum: int = 3) -> None:
|
|
13
|
+
if max_pending <= 0:
|
|
14
|
+
raise ValueError(f'max_pending must be > 0, got {max_pending}')
|
|
15
|
+
self._max_pending: int = max_pending
|
|
16
|
+
self._overload_policy: OverloadPolicy = overload_policy
|
|
17
|
+
self._aging_threshold: float = aging_threshold_seconds
|
|
18
|
+
self._guild_quantum: int = guild_quantum
|
|
19
|
+
self._size: int = 0
|
|
20
|
+
self._sequence_counter: int = 0
|
|
21
|
+
self._high_water_mark: int = 0
|
|
22
|
+
self._queues: Dict[Priority, Dict[Optional[int], Deque[RequestItem]]] = {
|
|
23
|
+
p: collections.defaultdict(collections.deque) for p in Priority}
|
|
24
|
+
self._active_guild_keys: Dict[Priority, List[Optional[int]]] = {
|
|
25
|
+
p: [] for p in Priority}
|
|
26
|
+
self._guild_indices: Dict[Priority, int] = {p: 0 for p in Priority}
|
|
27
|
+
self._guild_deficits: Dict[Optional[int],
|
|
28
|
+
int] = collections.defaultdict(int)
|
|
29
|
+
self._not_empty: asyncio.Event = asyncio.Event()
|
|
30
|
+
self._not_full: asyncio.Event = asyncio.Event()
|
|
31
|
+
self._not_full.set()
|
|
32
|
+
self._lock: asyncio.Lock = asyncio.Lock()
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def size(self) -> int:
|
|
36
|
+
return self._size
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def max_pending(self) -> int:
|
|
40
|
+
return self._max_pending
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def high_water_mark(self) -> int:
|
|
44
|
+
return self._high_water_mark
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def overload_policy(self) -> OverloadPolicy:
|
|
48
|
+
return self._overload_policy
|
|
49
|
+
|
|
50
|
+
def is_empty(self) -> bool:
|
|
51
|
+
return self._size == 0
|
|
52
|
+
|
|
53
|
+
def is_full(self) -> bool:
|
|
54
|
+
return self._size >= self._max_pending
|
|
55
|
+
|
|
56
|
+
def get_priority_counts(self) -> Dict[str, int]:
|
|
57
|
+
counts = {}
|
|
58
|
+
for p in Priority:
|
|
59
|
+
total = sum((len(q) for q in self._queues[p].values()))
|
|
60
|
+
counts[p.name.lower()] = total
|
|
61
|
+
return counts
|
|
62
|
+
|
|
63
|
+
def get_active_guild_count(self) -> int:
|
|
64
|
+
seen: Set[Optional[int]] = set()
|
|
65
|
+
for p in Priority:
|
|
66
|
+
for guild_id, q in self._queues[p].items():
|
|
67
|
+
if len(q) > 0:
|
|
68
|
+
seen.add(guild_id)
|
|
69
|
+
return len(seen)
|
|
70
|
+
|
|
71
|
+
async def put(self, item: RequestItem, timeout: Optional[float] = None) -> None:
|
|
72
|
+
async with self._lock:
|
|
73
|
+
if self._size >= self._max_pending:
|
|
74
|
+
if self._overload_policy == OverloadPolicy.REJECT:
|
|
75
|
+
raise QueueFullError(
|
|
76
|
+
f'queue is full ({self._size}/{self._max_pending}). workload rejected.')
|
|
77
|
+
elif self._overload_policy == OverloadPolicy.DROP_OLDEST:
|
|
78
|
+
self._drop_oldest_locked()
|
|
79
|
+
elif self._overload_policy == OverloadPolicy.WAIT:
|
|
80
|
+
self._not_full.clear()
|
|
81
|
+
if self._overload_policy == OverloadPolicy.WAIT and self._size >= self._max_pending:
|
|
82
|
+
start_wait = time.monotonic()
|
|
83
|
+
while True:
|
|
84
|
+
remaining_timeout = None
|
|
85
|
+
if timeout is not None:
|
|
86
|
+
elapsed = time.monotonic() - start_wait
|
|
87
|
+
remaining_timeout = timeout - elapsed
|
|
88
|
+
if remaining_timeout <= 0:
|
|
89
|
+
raise QueueFullError(
|
|
90
|
+
'timed out waiting for queue capacity.')
|
|
91
|
+
try:
|
|
92
|
+
await asyncio.wait_for(self._not_full.wait(), timeout=remaining_timeout)
|
|
93
|
+
except asyncio.TimeoutError:
|
|
94
|
+
raise QueueFullError(
|
|
95
|
+
'timed out waiting for queue capacity.')
|
|
96
|
+
async with self._lock:
|
|
97
|
+
if self._size < self._max_pending:
|
|
98
|
+
break
|
|
99
|
+
async with self._lock:
|
|
100
|
+
self._enqueue_item_locked(item)
|
|
101
|
+
|
|
102
|
+
def _enqueue_item_locked(self, item: RequestItem) -> None:
|
|
103
|
+
self._sequence_counter += 1
|
|
104
|
+
item.sequence = self._sequence_counter
|
|
105
|
+
p = item.priority
|
|
106
|
+
g = item.guild_id
|
|
107
|
+
guild_dict = self._queues[p]
|
|
108
|
+
if g not in guild_dict or len(guild_dict[g]) == 0:
|
|
109
|
+
if g not in self._active_guild_keys[p]:
|
|
110
|
+
self._active_guild_keys[p].append(g)
|
|
111
|
+
guild_dict[g].append(item)
|
|
112
|
+
self._size += 1
|
|
113
|
+
if self._size > self._high_water_mark:
|
|
114
|
+
self._high_water_mark = self._size
|
|
115
|
+
self._not_empty.set()
|
|
116
|
+
if self._size >= self._max_pending:
|
|
117
|
+
self._not_full.clear()
|
|
118
|
+
|
|
119
|
+
def _drop_oldest_locked(self) -> None:
|
|
120
|
+
for p in reversed(Priority):
|
|
121
|
+
guild_dict = self._queues[p]
|
|
122
|
+
oldest_item: Optional[RequestItem] = None
|
|
123
|
+
target_guild: Optional[int] = None
|
|
124
|
+
for g, q in guild_dict.items():
|
|
125
|
+
if q:
|
|
126
|
+
head = q[0]
|
|
127
|
+
if oldest_item is None or head.sequence < oldest_item.sequence:
|
|
128
|
+
oldest_item = head
|
|
129
|
+
target_guild = g
|
|
130
|
+
if oldest_item is not None and target_guild is not None:
|
|
131
|
+
dropped = guild_dict[target_guild].popleft()
|
|
132
|
+
if len(guild_dict[target_guild]) == 0:
|
|
133
|
+
if target_guild in self._active_guild_keys[p]:
|
|
134
|
+
self._active_guild_keys[p].remove(target_guild)
|
|
135
|
+
self._size -= 1
|
|
136
|
+
dropped.cancelled = True
|
|
137
|
+
if dropped.future and (not dropped.future.done()):
|
|
138
|
+
dropped.future.set_exception(QueueFullError(
|
|
139
|
+
'dropped due to queue overload under drop_oldest policy'))
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
def remove_item(self, item: RequestItem) -> bool:
|
|
143
|
+
p = item.effective_priority
|
|
144
|
+
g = item.guild_id
|
|
145
|
+
guild_dict = self._queues[p]
|
|
146
|
+
q = guild_dict.get(g)
|
|
147
|
+
if q:
|
|
148
|
+
try:
|
|
149
|
+
q.remove(item)
|
|
150
|
+
self._size -= 1
|
|
151
|
+
if len(q) == 0 and g in self._active_guild_keys[p]:
|
|
152
|
+
self._active_guild_keys[p].remove(g)
|
|
153
|
+
if self._size < self._max_pending:
|
|
154
|
+
self._not_full.set()
|
|
155
|
+
if self._size == 0:
|
|
156
|
+
self._not_empty.clear()
|
|
157
|
+
return True
|
|
158
|
+
except ValueError:
|
|
159
|
+
pass
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
async def get(self, is_route_limited: Optional[Callable[[Optional[str]], bool]] = None) -> RequestItem:
|
|
163
|
+
while True:
|
|
164
|
+
await self._not_empty.wait()
|
|
165
|
+
async with self._lock:
|
|
166
|
+
item = self._extract_next_item_locked(is_route_limited)
|
|
167
|
+
if item is not None:
|
|
168
|
+
self._size -= 1
|
|
169
|
+
if self._size < self._max_pending:
|
|
170
|
+
self._not_full.set()
|
|
171
|
+
if self._size == 0:
|
|
172
|
+
self._not_empty.clear()
|
|
173
|
+
return item
|
|
174
|
+
self._not_empty.clear()
|
|
175
|
+
await asyncio.sleep(0.01)
|
|
176
|
+
|
|
177
|
+
def _extract_next_item_locked(self, is_route_limited: Optional[Callable[[Optional[str]], bool]] = None) -> Optional[RequestItem]:
|
|
178
|
+
now = time.monotonic()
|
|
179
|
+
self._apply_aging_locked(now)
|
|
180
|
+
for p in Priority:
|
|
181
|
+
active_guilds = self._active_guild_keys[p]
|
|
182
|
+
if not active_guilds:
|
|
183
|
+
continue
|
|
184
|
+
num_guilds = len(active_guilds)
|
|
185
|
+
start_index = self._guild_indices[p] % num_guilds
|
|
186
|
+
for i in range(num_guilds):
|
|
187
|
+
idx = (start_index + i) % num_guilds
|
|
188
|
+
g = active_guilds[idx]
|
|
189
|
+
q = self._queues[p][g]
|
|
190
|
+
if not q:
|
|
191
|
+
continue
|
|
192
|
+
head = q[0]
|
|
193
|
+
if head.cancelled or (head.future and head.future.cancelled()):
|
|
194
|
+
q.popleft()
|
|
195
|
+
self._size -= 1
|
|
196
|
+
continue
|
|
197
|
+
if is_route_limited and is_route_limited(head.route_key):
|
|
198
|
+
continue
|
|
199
|
+
if self._guild_deficits[g] <= 0:
|
|
200
|
+
self._guild_deficits[g] += self._guild_quantum
|
|
201
|
+
self._guild_deficits[g] -= 1
|
|
202
|
+
item = q.popleft()
|
|
203
|
+
if len(q) == 0:
|
|
204
|
+
self._guild_deficits[g] = 0
|
|
205
|
+
active_guilds.remove(g)
|
|
206
|
+
if active_guilds:
|
|
207
|
+
self._guild_indices[p] = idx % len(active_guilds)
|
|
208
|
+
else:
|
|
209
|
+
self._guild_indices[p] = 0
|
|
210
|
+
elif self._guild_deficits[g] <= 0:
|
|
211
|
+
self._guild_deficits[g] = 0
|
|
212
|
+
self._guild_indices[p] = (idx + 1) % num_guilds
|
|
213
|
+
else:
|
|
214
|
+
self._guild_indices[p] = idx
|
|
215
|
+
return item
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
def _apply_aging_locked(self, now: float) -> None:
|
|
219
|
+
if self._aging_threshold <= 0:
|
|
220
|
+
return
|
|
221
|
+
for p in [Priority.LOW, Priority.NORMAL, Priority.HIGH]:
|
|
222
|
+
guild_dict = self._queues[p]
|
|
223
|
+
higher_priority = Priority(p.value - 1)
|
|
224
|
+
for g, q in list(guild_dict.items()):
|
|
225
|
+
stale_items = []
|
|
226
|
+
for item in list(q):
|
|
227
|
+
if now - item.enqueue_time >= self._aging_threshold:
|
|
228
|
+
stale_items.append(item)
|
|
229
|
+
for item in stale_items:
|
|
230
|
+
q.remove(item)
|
|
231
|
+
item.effective_priority = higher_priority
|
|
232
|
+
self._queues[higher_priority][g].append(item)
|
|
233
|
+
if g not in self._active_guild_keys[higher_priority]:
|
|
234
|
+
self._active_guild_keys[higher_priority].append(g)
|
|
235
|
+
if len(q) == 0 and g in self._active_guild_keys[p]:
|
|
236
|
+
self._active_guild_keys[p].remove(g)
|
requestflow/ratelimit.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from typing import Dict, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GlobalRateLimiter:
|
|
7
|
+
def __init__(self, rate: float = 40.0, ceiling: float = 50.0, burst_allowance: float = 5.0, allow_custom_ceiling: bool = False) -> None:
|
|
8
|
+
if rate <= 0:
|
|
9
|
+
raise ValueError(f'global rate limit must be positive, got {rate}')
|
|
10
|
+
if ceiling <= 0:
|
|
11
|
+
raise ValueError(
|
|
12
|
+
f'rate limit ceiling must be positive, got {ceiling}')
|
|
13
|
+
if not allow_custom_ceiling and rate > ceiling:
|
|
14
|
+
raise ValueError(
|
|
15
|
+
f'configured rate ({rate}) exceeds standard discord limit ceiling ({ceiling}). discord standard limit is 50 req/s. if you have an officially approved higher limit, set allow_custom_ceiling=true.')
|
|
16
|
+
self._rate: float = float(rate)
|
|
17
|
+
self._ceiling: float = float(ceiling)
|
|
18
|
+
self._burst_tokens: float = float(burst_allowance)
|
|
19
|
+
self._allow_custom_ceiling: bool = allow_custom_ceiling
|
|
20
|
+
self._emission_interval: float = 1.0 / self._rate
|
|
21
|
+
self._tau: float = self._burst_tokens * self._emission_interval
|
|
22
|
+
self._tat: float = 0.0
|
|
23
|
+
self._lock: asyncio.Lock = asyncio.Lock()
|
|
24
|
+
self._global_cooldown_until: float = 0.0
|
|
25
|
+
self._global_cooldown_event: asyncio.Event = asyncio.Event()
|
|
26
|
+
self._global_cooldown_event.set()
|
|
27
|
+
self._total_acquisitions: int = 0
|
|
28
|
+
self._total_wait_time: float = 0.0
|
|
29
|
+
self._cooldown_429_count: int = 0
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def rate(self) -> float:
|
|
33
|
+
return self._rate
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def ceiling(self) -> float:
|
|
37
|
+
return self._ceiling
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def is_cooling_down(self) -> bool:
|
|
41
|
+
return time.monotonic() < self._global_cooldown_until
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def cooldown_remaining(self) -> float:
|
|
45
|
+
now = time.monotonic()
|
|
46
|
+
return max(0.0, self._global_cooldown_until - now)
|
|
47
|
+
|
|
48
|
+
async def update_rate(self, new_rate: float) -> None:
|
|
49
|
+
if new_rate <= 0:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f'global rate limit must be positive, got {new_rate}')
|
|
52
|
+
if not self._allow_custom_ceiling and new_rate > self._ceiling:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f'new rate ({new_rate}) exceeds ceiling ({self._ceiling}).')
|
|
55
|
+
async with self._lock:
|
|
56
|
+
self._rate = float(new_rate)
|
|
57
|
+
self._emission_interval = 1.0 / self._rate
|
|
58
|
+
self._tau = self._burst_tokens * self._emission_interval
|
|
59
|
+
self._tat = max(self._tat, time.monotonic())
|
|
60
|
+
|
|
61
|
+
async def acquire(self, *, is_interaction: bool = False) -> float:
|
|
62
|
+
if is_interaction:
|
|
63
|
+
return 0.0
|
|
64
|
+
wait_seconds: float = 0.0
|
|
65
|
+
while True:
|
|
66
|
+
if not self._global_cooldown_event.is_set():
|
|
67
|
+
await self._global_cooldown_event.wait()
|
|
68
|
+
now = time.monotonic()
|
|
69
|
+
if now < self._global_cooldown_until:
|
|
70
|
+
sleep_duration = self._global_cooldown_until - now
|
|
71
|
+
if sleep_duration > 0:
|
|
72
|
+
await asyncio.sleep(sleep_duration)
|
|
73
|
+
continue
|
|
74
|
+
async with self._lock:
|
|
75
|
+
now = time.monotonic()
|
|
76
|
+
if now > self._tat:
|
|
77
|
+
self._tat = now
|
|
78
|
+
dispatch_time = self._tat
|
|
79
|
+
self._tat = dispatch_time + self._emission_interval
|
|
80
|
+
delay = max(0.0, dispatch_time - now)
|
|
81
|
+
if delay > 0:
|
|
82
|
+
try:
|
|
83
|
+
await asyncio.sleep(delay)
|
|
84
|
+
wait_seconds += delay
|
|
85
|
+
except asyncio.CancelledError:
|
|
86
|
+
async with self._lock:
|
|
87
|
+
now_cancel = time.monotonic()
|
|
88
|
+
if self._tat > now_cancel:
|
|
89
|
+
self._tat = max(
|
|
90
|
+
now_cancel, self._tat - self._emission_interval)
|
|
91
|
+
raise
|
|
92
|
+
self._total_acquisitions += 1
|
|
93
|
+
self._total_wait_time += wait_seconds
|
|
94
|
+
return wait_seconds
|
|
95
|
+
|
|
96
|
+
def handle_global_429(self, retry_after: float) -> None:
|
|
97
|
+
now = time.monotonic()
|
|
98
|
+
cooldown_end = now + max(0.05, retry_after)
|
|
99
|
+
if cooldown_end > self._global_cooldown_until:
|
|
100
|
+
self._global_cooldown_until = cooldown_end
|
|
101
|
+
self._cooldown_429_count += 1
|
|
102
|
+
self._global_cooldown_event.clear()
|
|
103
|
+
loop = asyncio.get_event_loop()
|
|
104
|
+
loop.call_later(max(0.05, retry_after),
|
|
105
|
+
self._clear_cooldown, cooldown_end)
|
|
106
|
+
|
|
107
|
+
def _clear_cooldown(self, target_end: float) -> None:
|
|
108
|
+
if time.monotonic() >= self._global_cooldown_until or target_end == self._global_cooldown_until:
|
|
109
|
+
self._global_cooldown_event.set()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class RouteBucketCoordinator:
|
|
113
|
+
def __init__(self) -> None:
|
|
114
|
+
self._cooldowns: Dict[str, float] = {}
|
|
115
|
+
self._remaining: Dict[str, int] = {}
|
|
116
|
+
self._lock: asyncio.Lock = asyncio.Lock()
|
|
117
|
+
self._route_429_count: int = 0
|
|
118
|
+
|
|
119
|
+
def is_route_cooling_down(self, route_key: Optional[str]) -> bool:
|
|
120
|
+
if not route_key:
|
|
121
|
+
return False
|
|
122
|
+
expiry = self._cooldowns.get(route_key)
|
|
123
|
+
if expiry is None:
|
|
124
|
+
return False
|
|
125
|
+
if time.monotonic() >= expiry:
|
|
126
|
+
self._cooldowns.pop(route_key, None)
|
|
127
|
+
return False
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
def get_route_delay(self, route_key: Optional[str]) -> float:
|
|
131
|
+
if not route_key:
|
|
132
|
+
return 0.0
|
|
133
|
+
expiry = self._cooldowns.get(route_key)
|
|
134
|
+
if expiry is None:
|
|
135
|
+
return 0.0
|
|
136
|
+
remaining = expiry - time.monotonic()
|
|
137
|
+
if remaining <= 0:
|
|
138
|
+
self._cooldowns.pop(route_key, None)
|
|
139
|
+
return 0.0
|
|
140
|
+
return remaining
|
|
141
|
+
|
|
142
|
+
def update_route_cooldown(self, route_key: str, retry_after: float) -> None:
|
|
143
|
+
if not route_key or retry_after <= 0:
|
|
144
|
+
return
|
|
145
|
+
cooldown_until = time.monotonic() + retry_after
|
|
146
|
+
existing = self._cooldowns.get(route_key, 0.0)
|
|
147
|
+
if cooldown_until > existing:
|
|
148
|
+
self._cooldowns[route_key] = cooldown_until
|
|
149
|
+
self._route_429_count += 1
|
|
150
|
+
|
|
151
|
+
def update_from_headers(self, route_key: str, headers: Dict[str, str]) -> None:
|
|
152
|
+
if not route_key:
|
|
153
|
+
return
|
|
154
|
+
remaining = headers.get('X-RateLimit-Remaining')
|
|
155
|
+
reset_after = headers.get('X-RateLimit-Reset-After')
|
|
156
|
+
if remaining is not None:
|
|
157
|
+
try:
|
|
158
|
+
rem_val = int(remaining)
|
|
159
|
+
self._remaining[route_key] = rem_val
|
|
160
|
+
if rem_val == 0 and reset_after is not None:
|
|
161
|
+
try:
|
|
162
|
+
reset_val = float(reset_after)
|
|
163
|
+
self.update_route_cooldown(route_key, reset_val)
|
|
164
|
+
except ValueError:
|
|
165
|
+
pass
|
|
166
|
+
except ValueError:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class RateLimitCoordinator:
|
|
171
|
+
def __init__(self, global_rate_limit: float = 40.0, rate_limit_ceiling: float = 50.0, burst_allowance: float = 5.0, allow_custom_ceiling: bool = False) -> None:
|
|
172
|
+
self.global_limiter = GlobalRateLimiter(
|
|
173
|
+
rate=global_rate_limit, ceiling=rate_limit_ceiling, burst_allowance=burst_allowance, allow_custom_ceiling=allow_custom_ceiling)
|
|
174
|
+
self.route_coordinator = RouteBucketCoordinator()
|
|
175
|
+
|
|
176
|
+
async def acquire(self, route_key: Optional[str] = None, *, is_interaction: bool = False) -> float:
|
|
177
|
+
if route_key and self.route_coordinator.is_route_cooling_down(route_key):
|
|
178
|
+
delay = self.route_coordinator.get_route_delay(route_key)
|
|
179
|
+
if delay > 0:
|
|
180
|
+
await asyncio.sleep(delay)
|
|
181
|
+
return await self.global_limiter.acquire(is_interaction=is_interaction)
|
|
182
|
+
|
|
183
|
+
def handle_429(self, retry_after: float, *, is_global: bool = False, route_key: Optional[str] = None) -> None:
|
|
184
|
+
if is_global:
|
|
185
|
+
self.global_limiter.handle_global_429(retry_after)
|
|
186
|
+
elif route_key:
|
|
187
|
+
self.route_coordinator.update_route_cooldown(
|
|
188
|
+
route_key, retry_after)
|
requestflow/scheduler.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any, Callable, Coroutine, List, Optional, Set, TypeVar
|
|
4
|
+
from requestflow.exceptions import OperationCancelledError, QueueFullError, SchedulerClosedError, SchedulerNotStartedError
|
|
5
|
+
from requestflow.executor import RequestExecutor
|
|
6
|
+
from requestflow.metrics import RequestMetrics
|
|
7
|
+
from requestflow.models import RequestItem
|
|
8
|
+
from requestflow.policies import OverloadPolicy, RetryPolicy
|
|
9
|
+
from requestflow.priorities import Priority
|
|
10
|
+
from requestflow.queue import RequestQueue
|
|
11
|
+
from requestflow.ratelimit import RateLimitCoordinator
|
|
12
|
+
T = TypeVar('t')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RequestScheduler:
|
|
16
|
+
def __init__(self, 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: OverloadPolicy = OverloadPolicy.REJECT, aging_threshold_seconds: float = 2.0, guild_quantum: int = 3, default_timeout: Optional[float] = None, default_retry_policy: Optional[RetryPolicy] = None) -> None:
|
|
17
|
+
self.queue = RequestQueue(max_pending=max_pending, overload_policy=overload_policy,
|
|
18
|
+
aging_threshold_seconds=aging_threshold_seconds, guild_quantum=guild_quantum)
|
|
19
|
+
self.executor = RequestExecutor(
|
|
20
|
+
max_concurrency=max_concurrency, default_timeout=default_timeout, default_retry_policy=default_retry_policy)
|
|
21
|
+
self.rate_coordinator = RateLimitCoordinator(global_rate_limit=global_rate_limit, rate_limit_ceiling=rate_limit_ceiling,
|
|
22
|
+
burst_allowance=burst_allowance, allow_custom_ceiling=allow_custom_ceiling)
|
|
23
|
+
self.metrics = RequestMetrics()
|
|
24
|
+
self.metrics.register_providers(get_currently_queued=lambda: self.queue.size, get_currently_executing=lambda: self.executor.currently_executing,
|
|
25
|
+
get_high_water_mark=lambda: self.queue.high_water_mark, get_per_priority=self.queue.get_priority_counts, get_active_guilds=self.queue.get_active_guild_count)
|
|
26
|
+
self._is_started: bool = False
|
|
27
|
+
self._is_closed: bool = False
|
|
28
|
+
self._accepting_requests: bool = False
|
|
29
|
+
self._dispatch_tasks: List[asyncio.Task] = []
|
|
30
|
+
self._in_flight_tasks: Set[asyncio.Task] = set()
|
|
31
|
+
self._drain_event: asyncio.Event = asyncio.Event()
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def is_started(self) -> bool:
|
|
35
|
+
return self._is_started
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def is_closed(self) -> bool:
|
|
39
|
+
return self._is_closed
|
|
40
|
+
|
|
41
|
+
async def start(self) -> None:
|
|
42
|
+
if self._is_closed:
|
|
43
|
+
raise SchedulerClosedError('cannot start a closed scheduler.')
|
|
44
|
+
if self._is_started:
|
|
45
|
+
return
|
|
46
|
+
self._is_started = True
|
|
47
|
+
self._accepting_requests = True
|
|
48
|
+
self._dispatch_tasks = [asyncio.create_task(
|
|
49
|
+
self._dispatch_loop(), name='requestflow_dispatcher')]
|
|
50
|
+
|
|
51
|
+
async def close(self, drain: bool = True, timeout: float = 5.0) -> None:
|
|
52
|
+
if self._is_closed:
|
|
53
|
+
return
|
|
54
|
+
self._accepting_requests = False
|
|
55
|
+
if drain and self._is_started and (self.queue.size > 0 or len(self._in_flight_tasks) > 0):
|
|
56
|
+
start_drain = time.monotonic()
|
|
57
|
+
while time.monotonic() - start_drain < timeout:
|
|
58
|
+
if self.queue.size == 0 and len(self._in_flight_tasks) == 0:
|
|
59
|
+
break
|
|
60
|
+
await asyncio.sleep(0.01)
|
|
61
|
+
self._is_closed = True
|
|
62
|
+
for t in self._dispatch_tasks:
|
|
63
|
+
t.cancel()
|
|
64
|
+
for t in self._dispatch_tasks:
|
|
65
|
+
try:
|
|
66
|
+
await t
|
|
67
|
+
except (asyncio.CancelledError, Exception):
|
|
68
|
+
pass
|
|
69
|
+
self._dispatch_tasks.clear()
|
|
70
|
+
for t in list(self._in_flight_tasks):
|
|
71
|
+
t.cancel()
|
|
72
|
+
self._in_flight_tasks.clear()
|
|
73
|
+
while not self.queue.is_empty():
|
|
74
|
+
try:
|
|
75
|
+
item = await self.queue.get()
|
|
76
|
+
if item.future and (not item.future.done()):
|
|
77
|
+
item.future.set_exception(SchedulerClosedError(
|
|
78
|
+
'scheduler closed before item could be executed.'))
|
|
79
|
+
except Exception:
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
async def submit(self, operation: Callable[..., Coroutine[Any, Any, T]], *args: Any, priority: Priority = 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:
|
|
83
|
+
if not self._is_started:
|
|
84
|
+
raise SchedulerNotStartedError(
|
|
85
|
+
"scheduler has not been started. call 'await flow.start()'.")
|
|
86
|
+
if not self._accepting_requests or self._is_closed:
|
|
87
|
+
raise SchedulerClosedError(
|
|
88
|
+
'scheduler is closed. cannot accept new submissions.')
|
|
89
|
+
loop = asyncio.get_running_loop()
|
|
90
|
+
future = loop.create_future()
|
|
91
|
+
item = RequestItem(sequence=0, operation=operation, args=args, kwargs=kwargs, priority=priority, effective_priority=priority, guild_id=guild_id,
|
|
92
|
+
route_key=route_key, is_interaction=is_interaction, dedup_key=dedup_key, timeout=timeout, future=future, enqueue_time=time.monotonic())
|
|
93
|
+
self.metrics.record_submit()
|
|
94
|
+
try:
|
|
95
|
+
await self.queue.put(item, timeout=enqueue_timeout)
|
|
96
|
+
except QueueFullError:
|
|
97
|
+
self.metrics.record_reject()
|
|
98
|
+
raise
|
|
99
|
+
try:
|
|
100
|
+
return await future
|
|
101
|
+
except asyncio.CancelledError:
|
|
102
|
+
item.cancelled = True
|
|
103
|
+
self.metrics.record_cancel()
|
|
104
|
+
self.queue.remove_item(item)
|
|
105
|
+
raise
|
|
106
|
+
|
|
107
|
+
async def _dispatch_loop(self) -> None:
|
|
108
|
+
while self._is_started and (not self._is_closed):
|
|
109
|
+
try:
|
|
110
|
+
item = await self.queue.get(is_route_limited=self.rate_coordinator.route_coordinator.is_route_cooling_down)
|
|
111
|
+
if item.cancelled or (item.future and item.future.cancelled()):
|
|
112
|
+
continue
|
|
113
|
+
wait_time = await self.rate_coordinator.acquire(item.route_key, is_interaction=item.is_interaction)
|
|
114
|
+
self.metrics.record_rate_limit_wait(wait_time)
|
|
115
|
+
task = asyncio.create_task(self._execute_item_task(
|
|
116
|
+
item), name=f'requestflow_exec_{item.sequence}')
|
|
117
|
+
self._in_flight_tasks.add(task)
|
|
118
|
+
task.add_done_callback(self._in_flight_tasks.discard)
|
|
119
|
+
except asyncio.CancelledError:
|
|
120
|
+
break
|
|
121
|
+
except Exception as e:
|
|
122
|
+
await asyncio.sleep(0.01)
|
|
123
|
+
|
|
124
|
+
async def _execute_item_task(self, item: RequestItem) -> None:
|
|
125
|
+
def on_complete(it: RequestItem, err: Optional[BaseException]) -> None:
|
|
126
|
+
self.metrics.record_completion(
|
|
127
|
+
queue_wait_seconds=it.queue_wait_time, exec_duration_seconds=it.execution_duration, error=err)
|
|
128
|
+
try:
|
|
129
|
+
await self.executor.execute(item, on_complete=on_complete)
|
|
130
|
+
except asyncio.CancelledError:
|
|
131
|
+
pass
|