requestflow 0.1.0__tar.gz → 0.1.1__tar.gz
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-0.1.0/src/requestflow.egg-info → requestflow-0.1.1}/PKG-INFO +1 -1
- {requestflow-0.1.0 → requestflow-0.1.1}/pyproject.toml +73 -73
- {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/__init__.py +1 -1
- {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/flow.py +1 -1
- {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/integrations/discord_py.py +209 -115
- {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/models.py +2 -2
- {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/queue.py +6 -6
- {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/ratelimit.py +11 -5
- {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/scheduler.py +2 -2
- {requestflow-0.1.0 → requestflow-0.1.1/src/requestflow.egg-info}/PKG-INFO +1 -1
- {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_discord_integration.py +98 -66
- {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_ratelimit.py +103 -89
- {requestflow-0.1.0 → requestflow-0.1.1}/LICENSE +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/README.md +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/setup.cfg +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/deduplication.py +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/exceptions.py +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/executor.py +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/integrations/__init__.py +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/metrics.py +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/policies.py +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/priorities.py +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/py.typed +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow.egg-info/SOURCES.txt +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow.egg-info/dependency_links.txt +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow.egg-info/requires.txt +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow.egg-info/top_level.txt +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_burst_stress.py +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_cancellation.py +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_deduplication.py +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_executor.py +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_fairness.py +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_lifecycle.py +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_metrics.py +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_priorities.py +0 -0
- {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_queue.py +0 -0
|
@@ -1,73 +1,73 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["setuptools>=61.0.0", "wheel"]
|
|
3
|
-
build-backend = "setuptools.build_meta"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "requestflow"
|
|
7
|
-
version = "0.1.
|
|
8
|
-
description = "stop getting 429 ratelimited like an amateur. high-performance http scheduling and pacing for discord.py."
|
|
9
|
-
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.11"
|
|
11
|
-
license = "MIT"
|
|
12
|
-
authors = [
|
|
13
|
-
{ name = "requestflow" }
|
|
14
|
-
]
|
|
15
|
-
keywords = [
|
|
16
|
-
"discord",
|
|
17
|
-
"discord.py",
|
|
18
|
-
"http",
|
|
19
|
-
"ratelimit",
|
|
20
|
-
"queue",
|
|
21
|
-
"scheduler",
|
|
22
|
-
"asyncio",
|
|
23
|
-
"performance"
|
|
24
|
-
]
|
|
25
|
-
classifiers = [
|
|
26
|
-
"Development Status :: 5 - Production/Stable",
|
|
27
|
-
"Framework :: AsyncIO",
|
|
28
|
-
"Intended Audience :: Developers",
|
|
29
|
-
"Operating System :: OS Independent",
|
|
30
|
-
"Programming Language :: Python :: 3",
|
|
31
|
-
"Programming Language :: Python :: 3.11",
|
|
32
|
-
"Programming Language :: Python :: 3.12",
|
|
33
|
-
"Programming Language :: Python :: 3.13",
|
|
34
|
-
"Topic :: Communications :: Chat",
|
|
35
|
-
"Topic :: Internet :: WWW/HTTP",
|
|
36
|
-
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
37
|
-
"Typing :: Typed"
|
|
38
|
-
]
|
|
39
|
-
dependencies = [
|
|
40
|
-
"discord.py>=2.0.0"
|
|
41
|
-
]
|
|
42
|
-
|
|
43
|
-
[project.optional-dependencies]
|
|
44
|
-
dev = [
|
|
45
|
-
"pytest>=8.0.0",
|
|
46
|
-
"pytest-asyncio>=0.23.0",
|
|
47
|
-
"coverage>=7.0.0",
|
|
48
|
-
"setuptools>=61.0.0",
|
|
49
|
-
"wheel"
|
|
50
|
-
]
|
|
51
|
-
|
|
52
|
-
[project.urls]
|
|
53
|
-
Repository = "https://github.com/snipe1337/requestflow"
|
|
54
|
-
Issues = "https://github.com/snipe1337/requestflow/issues"
|
|
55
|
-
Community = "https://discord.gg/cse"
|
|
56
|
-
Support = "https://discord.gg/cse"
|
|
57
|
-
|
|
58
|
-
[tool.setuptools.packages.find]
|
|
59
|
-
where = ["src"]
|
|
60
|
-
|
|
61
|
-
[tool.setuptools.package-data]
|
|
62
|
-
requestflow = ["py.typed"]
|
|
63
|
-
|
|
64
|
-
[tool.pytest.ini_options]
|
|
65
|
-
minversion = "8.0"
|
|
66
|
-
testpaths = ["tests"]
|
|
67
|
-
pythonpath = ["src"]
|
|
68
|
-
asyncio_mode = "auto"
|
|
69
|
-
asyncio_default_fixture_loop_scope = "function"
|
|
70
|
-
filterwarnings = [
|
|
71
|
-
"ignore::DeprecationWarning",
|
|
72
|
-
"ignore::UserWarning"
|
|
73
|
-
]
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "requestflow"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "stop getting 429 ratelimited like an amateur. high-performance http scheduling and pacing for discord.py."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "requestflow" }
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"discord",
|
|
17
|
+
"discord.py",
|
|
18
|
+
"http",
|
|
19
|
+
"ratelimit",
|
|
20
|
+
"queue",
|
|
21
|
+
"scheduler",
|
|
22
|
+
"asyncio",
|
|
23
|
+
"performance"
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 5 - Production/Stable",
|
|
27
|
+
"Framework :: AsyncIO",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"Operating System :: OS Independent",
|
|
30
|
+
"Programming Language :: Python :: 3",
|
|
31
|
+
"Programming Language :: Python :: 3.11",
|
|
32
|
+
"Programming Language :: Python :: 3.12",
|
|
33
|
+
"Programming Language :: Python :: 3.13",
|
|
34
|
+
"Topic :: Communications :: Chat",
|
|
35
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
36
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
37
|
+
"Typing :: Typed"
|
|
38
|
+
]
|
|
39
|
+
dependencies = [
|
|
40
|
+
"discord.py>=2.0.0"
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.optional-dependencies]
|
|
44
|
+
dev = [
|
|
45
|
+
"pytest>=8.0.0",
|
|
46
|
+
"pytest-asyncio>=0.23.0",
|
|
47
|
+
"coverage>=7.0.0",
|
|
48
|
+
"setuptools>=61.0.0",
|
|
49
|
+
"wheel"
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[project.urls]
|
|
53
|
+
Repository = "https://github.com/snipe1337/requestflow"
|
|
54
|
+
Issues = "https://github.com/snipe1337/requestflow/issues"
|
|
55
|
+
Community = "https://discord.gg/cse"
|
|
56
|
+
Support = "https://discord.gg/cse"
|
|
57
|
+
|
|
58
|
+
[tool.setuptools.packages.find]
|
|
59
|
+
where = ["src"]
|
|
60
|
+
|
|
61
|
+
[tool.setuptools.package-data]
|
|
62
|
+
requestflow = ["py.typed"]
|
|
63
|
+
|
|
64
|
+
[tool.pytest.ini_options]
|
|
65
|
+
minversion = "8.0"
|
|
66
|
+
testpaths = ["tests"]
|
|
67
|
+
pythonpath = ["src"]
|
|
68
|
+
asyncio_mode = "auto"
|
|
69
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
70
|
+
filterwarnings = [
|
|
71
|
+
"ignore::DeprecationWarning",
|
|
72
|
+
"ignore::UserWarning"
|
|
73
|
+
]
|
|
@@ -10,6 +10,6 @@ from requestflow.priorities import Priority
|
|
|
10
10
|
from requestflow.queue import RequestQueue
|
|
11
11
|
from requestflow.ratelimit import GlobalRateLimiter, RateLimitCoordinator, RouteBucketCoordinator
|
|
12
12
|
from requestflow.scheduler import RequestScheduler
|
|
13
|
-
__version__ = '0.1.
|
|
13
|
+
__version__ = '0.1.1'
|
|
14
14
|
__all__ = ['__version__', 'requestflow', 'priority', 'overloadpolicy', 'pacingstrategy', 'retrypolicy', 'requestmetrics', 'metricssnapshot', 'requestitem', 'requestscheduler', 'requestqueue', 'requestexecutor', 'ratelimitcoordinator', 'globalratelimiter', 'routebucketcoordinator',
|
|
15
15
|
'inflightdeduplicator', 'resultcache', 'discordhttpadapter', 'attach_to_bot', 'requestflowerror', 'queuefullerror', 'schedulerclosederror', 'schedulernotstartederror', 'requesttimeouterror', 'ratelimitexceedederror', 'integrationerror', 'unsupporteddiscordversionerror', 'operationcancellederror']
|
|
@@ -64,7 +64,7 @@ class RequestFlow:
|
|
|
64
64
|
async def update_rate(self, new_rate: float) -> None:
|
|
65
65
|
await self.scheduler.rate_coordinator.global_limiter.update_rate(new_rate)
|
|
66
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:
|
|
67
|
+
async def submit(self, operation: Callable[..., Coroutine[Any, Any, T]], *args: Any, priority: Union[Priority, str, int] = Priority.NORMAL, guild_id: Optional[Union[int, str]] = 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
68
|
parsed_priority = Priority.from_value(priority)
|
|
69
69
|
if dedup_key is not None:
|
|
70
70
|
async def run_single() -> T:
|
|
@@ -1,115 +1,209 @@
|
|
|
1
|
-
import functools
|
|
2
|
-
import inspect
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import discord
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
from requestflow.
|
|
12
|
-
from requestflow.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
self.
|
|
109
|
-
self.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
1
|
+
import functools
|
|
2
|
+
import inspect
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Callable, Optional, Sequence, Union
|
|
5
|
+
try:
|
|
6
|
+
import discord
|
|
7
|
+
import discord.http
|
|
8
|
+
HAS_DISCORD = True
|
|
9
|
+
except ImportError:
|
|
10
|
+
HAS_DISCORD = False
|
|
11
|
+
from requestflow.exceptions import IntegrationError, UnsupportedDiscordVersionError
|
|
12
|
+
from requestflow.priorities import Priority
|
|
13
|
+
from requestflow.scheduler import RequestScheduler
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def extract_resource_id(route: Any, client: Optional[Any] = None) -> Optional[Union[int, str]]:
|
|
17
|
+
guild_id = getattr(route, 'guild_id', None)
|
|
18
|
+
if guild_id is None and hasattr(route, 'parameters') and isinstance(route.parameters, dict):
|
|
19
|
+
guild_id = route.parameters.get('guild_id')
|
|
20
|
+
if guild_id is not None:
|
|
21
|
+
try:
|
|
22
|
+
return int(guild_id)
|
|
23
|
+
except (ValueError, TypeError):
|
|
24
|
+
return guild_id
|
|
25
|
+
channel_id = getattr(route, 'channel_id', None)
|
|
26
|
+
if channel_id is None and hasattr(route, 'parameters') and isinstance(route.parameters, dict):
|
|
27
|
+
channel_id = route.parameters.get('channel_id')
|
|
28
|
+
if channel_id is not None:
|
|
29
|
+
if client is not None:
|
|
30
|
+
get_channel = getattr(client, 'get_channel', None)
|
|
31
|
+
if callable(get_channel):
|
|
32
|
+
try:
|
|
33
|
+
ch = get_channel(int(channel_id))
|
|
34
|
+
if ch is not None:
|
|
35
|
+
g = getattr(ch, 'guild', None)
|
|
36
|
+
if g is not None and getattr(g, 'id', None) is not None:
|
|
37
|
+
return int(g.id)
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
state = getattr(client, '_connection', None) or getattr(client, '_state', None)
|
|
41
|
+
if state is not None:
|
|
42
|
+
state_get_channel = getattr(state, 'get_channel', None)
|
|
43
|
+
if callable(state_get_channel):
|
|
44
|
+
try:
|
|
45
|
+
ch = state_get_channel(int(channel_id))
|
|
46
|
+
if ch is not None:
|
|
47
|
+
g = getattr(ch, 'guild', None)
|
|
48
|
+
if g is not None and getattr(g, 'id', None) is not None:
|
|
49
|
+
return int(g.id)
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
try:
|
|
53
|
+
return int(channel_id)
|
|
54
|
+
except (ValueError, TypeError):
|
|
55
|
+
return str(channel_id)
|
|
56
|
+
webhook_id = getattr(route, 'webhook_id', None)
|
|
57
|
+
if webhook_id is None and hasattr(route, 'parameters') and isinstance(route.parameters, dict):
|
|
58
|
+
webhook_id = route.parameters.get('webhook_id')
|
|
59
|
+
if webhook_id is not None:
|
|
60
|
+
try:
|
|
61
|
+
return int(webhook_id)
|
|
62
|
+
except (ValueError, TypeError):
|
|
63
|
+
return str(webhook_id)
|
|
64
|
+
path = getattr(route, 'path', '') or ''
|
|
65
|
+
if '/guilds/' in path:
|
|
66
|
+
m = re.search(r'/guilds/(\d+)', path)
|
|
67
|
+
if m:
|
|
68
|
+
return int(m.group(1))
|
|
69
|
+
if '/channels/' in path:
|
|
70
|
+
m = re.search(r'/channels/(\d+)', path)
|
|
71
|
+
if m:
|
|
72
|
+
cid = int(m.group(1))
|
|
73
|
+
if client is not None:
|
|
74
|
+
get_channel = getattr(client, 'get_channel', None)
|
|
75
|
+
if callable(get_channel):
|
|
76
|
+
try:
|
|
77
|
+
ch = get_channel(cid)
|
|
78
|
+
if ch is not None:
|
|
79
|
+
g = getattr(ch, 'guild', None)
|
|
80
|
+
if g is not None and getattr(g, 'id', None) is not None:
|
|
81
|
+
return int(g.id)
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
state = getattr(client, '_connection', None) or getattr(client, '_state', None)
|
|
85
|
+
if state is not None:
|
|
86
|
+
state_get_channel = getattr(state, 'get_channel', None)
|
|
87
|
+
if callable(state_get_channel):
|
|
88
|
+
try:
|
|
89
|
+
ch = state_get_channel(cid)
|
|
90
|
+
if ch is not None:
|
|
91
|
+
g = getattr(ch, 'guild', None)
|
|
92
|
+
if g is not None and getattr(g, 'id', None) is not None:
|
|
93
|
+
return int(g.id)
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
return cid
|
|
97
|
+
if '/webhooks/' in path:
|
|
98
|
+
m = re.search(r'/webhooks/(\d+)', path)
|
|
99
|
+
if m:
|
|
100
|
+
return int(m.group(1))
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class DiscordHTTPAdapter:
|
|
105
|
+
MIN_SUPPORTED_DISCORD_VERSION = (2, 0, 0)
|
|
106
|
+
|
|
107
|
+
def __init__(self, scheduler: RequestScheduler) -> None:
|
|
108
|
+
self.scheduler: RequestScheduler = scheduler
|
|
109
|
+
self._attached_clients: set = set()
|
|
110
|
+
self._original_requests: dict = {}
|
|
111
|
+
self._bot_map: dict = {}
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def verify_compatibility(cls) -> None:
|
|
115
|
+
if not HAS_DISCORD:
|
|
116
|
+
raise IntegrationError(
|
|
117
|
+
'discord.py is not installed in the current environment.')
|
|
118
|
+
version_str = getattr(discord, '__version__', 'unknown')
|
|
119
|
+
try:
|
|
120
|
+
parts = version_str.split('.')
|
|
121
|
+
major = int(parts[0])
|
|
122
|
+
minor = int(parts[1].rstrip('abcdefghijklmnopqrstuvwxyz'))
|
|
123
|
+
if major < 2:
|
|
124
|
+
raise UnsupportedDiscordVersionError(
|
|
125
|
+
f'requestflow requires discord.py >= 2.0.0, but found {version_str}.', detected_version=version_str)
|
|
126
|
+
except (ValueError, IndexError):
|
|
127
|
+
if not hasattr(discord.http, 'httpclient') or not hasattr(discord.http, 'route'):
|
|
128
|
+
raise UnsupportedDiscordVersionError(
|
|
129
|
+
f'unsupported discord.py layout or version: {version_str}.', detected_version=version_str)
|
|
130
|
+
|
|
131
|
+
def attach(self, client: Any) -> None:
|
|
132
|
+
self.verify_compatibility()
|
|
133
|
+
http_client = getattr(client, 'http', None)
|
|
134
|
+
if http_client is None:
|
|
135
|
+
if isinstance(client, getattr(discord.http, 'httpclient', object)):
|
|
136
|
+
http_client = client
|
|
137
|
+
else:
|
|
138
|
+
raise IntegrationError(
|
|
139
|
+
f"target object {type(client).__name__} does not have an 'http' attribute.")
|
|
140
|
+
if not hasattr(http_client, 'request') or not callable(http_client.request):
|
|
141
|
+
raise IntegrationError(
|
|
142
|
+
f"http client {type(http_client).__name__} has no callable 'request' method.")
|
|
143
|
+
if http_client in self._attached_clients:
|
|
144
|
+
return
|
|
145
|
+
self._bot_map[http_client] = client
|
|
146
|
+
original_request = http_client.request
|
|
147
|
+
self._original_requests[http_client] = original_request
|
|
148
|
+
|
|
149
|
+
@functools.wraps(original_request)
|
|
150
|
+
async def scheduled_request(route: Any, *args: Any, **kwargs: Any) -> Any:
|
|
151
|
+
bot_client = self._bot_map.get(http_client)
|
|
152
|
+
guild_id = extract_resource_id(route, bot_client)
|
|
153
|
+
route_path = getattr(route, 'path', '')
|
|
154
|
+
route_key = getattr(
|
|
155
|
+
route, 'key', f"{getattr(route, 'method', 'GET')} {route_path}")
|
|
156
|
+
is_interaction = bool(
|
|
157
|
+
route_path and '/interactions/' in route_path)
|
|
158
|
+
if is_interaction:
|
|
159
|
+
priority = Priority.CRITICAL
|
|
160
|
+
elif getattr(route, 'method', '') == 'DELETE':
|
|
161
|
+
priority = Priority.HIGH
|
|
162
|
+
else:
|
|
163
|
+
priority = Priority.NORMAL
|
|
164
|
+
|
|
165
|
+
async def run_http() -> Any:
|
|
166
|
+
try:
|
|
167
|
+
return await original_request(route, *args, **kwargs)
|
|
168
|
+
except getattr(discord, 'ratelimited', Exception) as exc:
|
|
169
|
+
retry_after = getattr(exc, 'retry_after', 0.0)
|
|
170
|
+
if retry_after > 0:
|
|
171
|
+
self.scheduler.rate_coordinator.handle_429(
|
|
172
|
+
retry_after, is_global=False, route_key=route_key)
|
|
173
|
+
self.scheduler.metrics.record_429(is_global=False)
|
|
174
|
+
raise
|
|
175
|
+
except getattr(discord, 'httpexception', Exception) as exc:
|
|
176
|
+
if getattr(exc, 'status', None) == 429:
|
|
177
|
+
retry_after = 1.0
|
|
178
|
+
if hasattr(exc, 'response') and hasattr(exc.response, 'headers'):
|
|
179
|
+
retry_after = float(
|
|
180
|
+
exc.response.headers.get('Retry-After', 1.0))
|
|
181
|
+
self.scheduler.rate_coordinator.handle_429(
|
|
182
|
+
retry_after, is_global=False, route_key=route_key)
|
|
183
|
+
self.scheduler.metrics.record_429(is_global=False)
|
|
184
|
+
raise
|
|
185
|
+
return await self.scheduler.submit(run_http, priority=priority, guild_id=guild_id, route_key=route_key, is_interaction=is_interaction)
|
|
186
|
+
http_client.request = scheduled_request
|
|
187
|
+
self._attached_clients.add(http_client)
|
|
188
|
+
|
|
189
|
+
def detach(self, client: Any) -> None:
|
|
190
|
+
http_client = getattr(client, 'http', client)
|
|
191
|
+
if http_client in self._attached_clients:
|
|
192
|
+
self._bot_map.pop(http_client, None)
|
|
193
|
+
original = self._original_requests.pop(http_client, None)
|
|
194
|
+
if original:
|
|
195
|
+
http_client.request = original
|
|
196
|
+
self._attached_clients.remove(http_client)
|
|
197
|
+
|
|
198
|
+
def detach_all(self) -> None:
|
|
199
|
+
for http_client, original in list(self._original_requests.items()):
|
|
200
|
+
http_client.request = original
|
|
201
|
+
self._attached_clients.clear()
|
|
202
|
+
self._original_requests.clear()
|
|
203
|
+
self._bot_map.clear()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def attach_to_bot(scheduler: RequestScheduler, bot: Any) -> DiscordHTTPAdapter:
|
|
207
|
+
adapter = DiscordHTTPAdapter(scheduler)
|
|
208
|
+
adapter.attach(bot)
|
|
209
|
+
return adapter
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import time
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
|
-
from typing import Any, Callable, Coroutine, Dict, Generic, Optional, TypeVar
|
|
4
|
+
from typing import Any, Callable, Coroutine, Dict, Generic, Optional, TypeVar, Union
|
|
5
5
|
from requestflow.priorities import Priority
|
|
6
6
|
T = TypeVar('t')
|
|
7
7
|
|
|
@@ -14,7 +14,7 @@ class RequestItem(Generic[T]):
|
|
|
14
14
|
kwargs: dict = field(default_factory=dict)
|
|
15
15
|
priority: Priority = Priority.NORMAL
|
|
16
16
|
effective_priority: Priority = Priority.NORMAL
|
|
17
|
-
guild_id: Optional[int] = None
|
|
17
|
+
guild_id: Optional[Union[int, str]] = None
|
|
18
18
|
route_key: Optional[str] = None
|
|
19
19
|
is_interaction: bool = False
|
|
20
20
|
dedup_key: Optional[str] = None
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import collections
|
|
3
3
|
import time
|
|
4
|
-
from typing import Callable, Deque, Dict, List, Optional, Set
|
|
4
|
+
from typing import Callable, Deque, Dict, List, Optional, Set, Union
|
|
5
5
|
from requestflow.exceptions import QueueFullError
|
|
6
6
|
from requestflow.models import RequestItem
|
|
7
7
|
from requestflow.policies import OverloadPolicy
|
|
@@ -19,12 +19,12 @@ class RequestQueue:
|
|
|
19
19
|
self._size: int = 0
|
|
20
20
|
self._sequence_counter: int = 0
|
|
21
21
|
self._high_water_mark: int = 0
|
|
22
|
-
self._queues: Dict[Priority, Dict[Optional[int], Deque[RequestItem]]] = {
|
|
22
|
+
self._queues: Dict[Priority, Dict[Optional[Union[int, str]], Deque[RequestItem]]] = {
|
|
23
23
|
p: collections.defaultdict(collections.deque) for p in Priority}
|
|
24
|
-
self._active_guild_keys: Dict[Priority, List[Optional[int]]] = {
|
|
24
|
+
self._active_guild_keys: Dict[Priority, List[Optional[Union[int, str]]]] = {
|
|
25
25
|
p: [] for p in Priority}
|
|
26
26
|
self._guild_indices: Dict[Priority, int] = {p: 0 for p in Priority}
|
|
27
|
-
self._guild_deficits: Dict[Optional[int],
|
|
27
|
+
self._guild_deficits: Dict[Optional[Union[int, str]],
|
|
28
28
|
int] = collections.defaultdict(int)
|
|
29
29
|
self._not_empty: asyncio.Event = asyncio.Event()
|
|
30
30
|
self._not_full: asyncio.Event = asyncio.Event()
|
|
@@ -61,7 +61,7 @@ class RequestQueue:
|
|
|
61
61
|
return counts
|
|
62
62
|
|
|
63
63
|
def get_active_guild_count(self) -> int:
|
|
64
|
-
seen: Set[Optional[int]] = set()
|
|
64
|
+
seen: Set[Optional[Union[int, str]]] = set()
|
|
65
65
|
for p in Priority:
|
|
66
66
|
for guild_id, q in self._queues[p].items():
|
|
67
67
|
if len(q) > 0:
|
|
@@ -120,7 +120,7 @@ class RequestQueue:
|
|
|
120
120
|
for p in reversed(Priority):
|
|
121
121
|
guild_dict = self._queues[p]
|
|
122
122
|
oldest_item: Optional[RequestItem] = None
|
|
123
|
-
target_guild: Optional[int] = None
|
|
123
|
+
target_guild: Optional[Union[int, str]] = None
|
|
124
124
|
for g, q in guild_dict.items():
|
|
125
125
|
if q:
|
|
126
126
|
head = q[0]
|
|
@@ -36,6 +36,10 @@ class GlobalRateLimiter:
|
|
|
36
36
|
def ceiling(self) -> float:
|
|
37
37
|
return self._ceiling
|
|
38
38
|
|
|
39
|
+
@property
|
|
40
|
+
def burst_allowance(self) -> float:
|
|
41
|
+
return self._burst_tokens
|
|
42
|
+
|
|
39
43
|
@property
|
|
40
44
|
def is_cooling_down(self) -> bool:
|
|
41
45
|
return time.monotonic() < self._global_cooldown_until
|
|
@@ -73,11 +77,13 @@ class GlobalRateLimiter:
|
|
|
73
77
|
continue
|
|
74
78
|
async with self._lock:
|
|
75
79
|
now = time.monotonic()
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
tat = max(now, self._tat)
|
|
81
|
+
if tat - now <= self._tau + 1e-9:
|
|
82
|
+
delay = 0.0
|
|
83
|
+
self._tat = tat + self._emission_interval
|
|
84
|
+
else:
|
|
85
|
+
delay = max(0.0, (tat - self._tau) - now)
|
|
86
|
+
self._tat = tat + self._emission_interval
|
|
81
87
|
if delay > 0:
|
|
82
88
|
try:
|
|
83
89
|
await asyncio.sleep(delay)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import time
|
|
3
|
-
from typing import Any, Callable, Coroutine, List, Optional, Set, TypeVar
|
|
3
|
+
from typing import Any, Callable, Coroutine, List, Optional, Set, TypeVar, Union
|
|
4
4
|
from requestflow.exceptions import OperationCancelledError, QueueFullError, SchedulerClosedError, SchedulerNotStartedError
|
|
5
5
|
from requestflow.executor import RequestExecutor
|
|
6
6
|
from requestflow.metrics import RequestMetrics
|
|
@@ -79,7 +79,7 @@ class RequestScheduler:
|
|
|
79
79
|
except Exception:
|
|
80
80
|
break
|
|
81
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:
|
|
82
|
+
async def submit(self, operation: Callable[..., Coroutine[Any, Any, T]], *args: Any, priority: Priority = Priority.NORMAL, guild_id: Optional[Union[int, str]] = 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
83
|
if not self._is_started:
|
|
84
84
|
raise SchedulerNotStartedError(
|
|
85
85
|
"scheduler has not been started. call 'await flow.start()'.")
|
|
@@ -1,66 +1,98 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
from unittest.mock import AsyncMock, MagicMock
|
|
3
|
-
import pytest
|
|
4
|
-
import discord
|
|
5
|
-
from discord.http import Route
|
|
6
|
-
from requestflow.flow import RequestFlow
|
|
7
|
-
from requestflow.integrations.discord_py import DiscordHTTPAdapter, attach_to_bot
|
|
8
|
-
from requestflow.priorities import Priority
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class MockHTTPClient:
|
|
12
|
-
def __init__(self):
|
|
13
|
-
self.request = AsyncMock()
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class MockBot:
|
|
17
|
-
def __init__(self):
|
|
18
|
-
self.http = MockHTTPClient()
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def test_compatibility_check():
|
|
22
|
-
DiscordHTTPAdapter.verify_compatibility()
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
bot
|
|
49
|
-
|
|
50
|
-
route = Route(
|
|
51
|
-
'POST', '/
|
|
52
|
-
|
|
53
|
-
assert
|
|
54
|
-
|
|
55
|
-
assert flow.stats.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
1
|
+
import asyncio
|
|
2
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
3
|
+
import pytest
|
|
4
|
+
import discord
|
|
5
|
+
from discord.http import Route
|
|
6
|
+
from requestflow.flow import RequestFlow
|
|
7
|
+
from requestflow.integrations.discord_py import DiscordHTTPAdapter, attach_to_bot, extract_resource_id
|
|
8
|
+
from requestflow.priorities import Priority
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MockHTTPClient:
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self.request = AsyncMock()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MockBot:
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.http = MockHTTPClient()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_compatibility_check():
|
|
22
|
+
DiscordHTTPAdapter.verify_compatibility()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_resource_id_extraction():
|
|
26
|
+
route_guild = Route('GET', '/guilds/{guild_id}/members', guild_id=123)
|
|
27
|
+
assert extract_resource_id(route_guild) == 123
|
|
28
|
+
|
|
29
|
+
route_channel = Route('POST', '/channels/{channel_id}/messages', channel_id=456)
|
|
30
|
+
assert extract_resource_id(route_channel) == 456
|
|
31
|
+
|
|
32
|
+
route_webhook = Route('POST', '/webhooks/{webhook_id}/{webhook_token}', webhook_id=789, webhook_token='tok')
|
|
33
|
+
assert extract_resource_id(route_webhook) == 789
|
|
34
|
+
|
|
35
|
+
mock_channel = MagicMock()
|
|
36
|
+
mock_channel.guild.id = 999
|
|
37
|
+
mock_bot = MagicMock()
|
|
38
|
+
mock_bot.get_channel.return_value = mock_channel
|
|
39
|
+
assert extract_resource_id(route_channel, mock_bot) == 999
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.asyncio
|
|
43
|
+
async def test_transparent_adapter_mode_b():
|
|
44
|
+
async with RequestFlow() as flow:
|
|
45
|
+
bot = MockBot()
|
|
46
|
+
original_request = bot.http.request
|
|
47
|
+
original_request.return_value = {'id': '12345', 'content': 'hello'}
|
|
48
|
+
flow.attach(bot)
|
|
49
|
+
assert bot.http.request != original_request
|
|
50
|
+
route = Route(
|
|
51
|
+
'POST', '/channels/{channel_id}/messages', channel_id=98765)
|
|
52
|
+
result = await bot.http.request(route, json={'content': 'hello'})
|
|
53
|
+
assert result == {'id': '12345', 'content': 'hello'}
|
|
54
|
+
original_request.assert_called_once()
|
|
55
|
+
assert flow.stats.submitted == 1
|
|
56
|
+
assert flow.stats.completed == 1
|
|
57
|
+
flow.detach(bot)
|
|
58
|
+
assert bot.http.request == original_request
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.asyncio
|
|
62
|
+
async def test_automatic_cross_guild_fairness_partitioning():
|
|
63
|
+
async with RequestFlow() as flow:
|
|
64
|
+
bot = MockBot()
|
|
65
|
+
bot.http.request.return_value = {'status': 'ok'}
|
|
66
|
+
flow.attach(bot)
|
|
67
|
+
route_a = Route('POST', '/channels/{channel_id}/messages', channel_id=1001)
|
|
68
|
+
route_b = Route('POST', '/channels/{channel_id}/messages', channel_id=2002)
|
|
69
|
+
t1 = asyncio.create_task(bot.http.request(route_a))
|
|
70
|
+
t2 = asyncio.create_task(bot.http.request(route_b))
|
|
71
|
+
await asyncio.gather(t1, t2)
|
|
72
|
+
assert flow.stats.submitted == 2
|
|
73
|
+
assert flow.stats.completed == 2
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.asyncio
|
|
77
|
+
async def test_interaction_endpoint_critical_priority():
|
|
78
|
+
async with RequestFlow() as flow:
|
|
79
|
+
bot = MockBot()
|
|
80
|
+
bot.http.request.return_value = {'type': 4}
|
|
81
|
+
flow.attach(bot)
|
|
82
|
+
route = Route(
|
|
83
|
+
'POST', '/interactions/{interaction_id}/{interaction_token}/callback', interaction_id=111, interaction_token='xyz')
|
|
84
|
+
res = await bot.http.request(route, json={'type': 4})
|
|
85
|
+
assert res == {'type': 4}
|
|
86
|
+
assert flow.stats.submitted == 1
|
|
87
|
+
assert flow.stats.completed == 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.asyncio
|
|
91
|
+
async def test_managed_mode_a_with_discord_call():
|
|
92
|
+
async with RequestFlow() as flow:
|
|
93
|
+
mock_send = AsyncMock(
|
|
94
|
+
return_value={'id': 1, 'content': 'managed message'})
|
|
95
|
+
res = await flow.submit(mock_send, content='managed message', priority=Priority.HIGH, guild_id=123456)
|
|
96
|
+
assert res == {'id': 1, 'content': 'managed message'}
|
|
97
|
+
mock_send.assert_called_once_with(content='managed message')
|
|
98
|
+
assert flow.stats.completed == 1
|
|
@@ -1,89 +1,103 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import time
|
|
3
|
-
import pytest
|
|
4
|
-
from requestflow.ratelimit import GlobalRateLimiter, RateLimitCoordinator, RouteBucketCoordinator
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@pytest.mark.asyncio
|
|
8
|
-
async def test_global_rate_limiter_validation():
|
|
9
|
-
with pytest.raises(ValueError):
|
|
10
|
-
GlobalRateLimiter(rate=0)
|
|
11
|
-
with pytest.raises(ValueError):
|
|
12
|
-
GlobalRateLimiter(rate=-10)
|
|
13
|
-
with pytest.raises(ValueError):
|
|
14
|
-
GlobalRateLimiter(rate=60.0, ceiling=50.0, allow_custom_ceiling=False)
|
|
15
|
-
limiter = GlobalRateLimiter(
|
|
16
|
-
rate=100.0, ceiling=50.0, allow_custom_ceiling=True)
|
|
17
|
-
assert limiter.rate == 100.0
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
@pytest.mark.asyncio
|
|
21
|
-
async def test_interaction_bypass_pacing():
|
|
22
|
-
limiter = GlobalRateLimiter(rate=5.0, burst_allowance=1.0)
|
|
23
|
-
t0 = time.monotonic()
|
|
24
|
-
for _ in range(5):
|
|
25
|
-
delay = await limiter.acquire(is_interaction=True)
|
|
26
|
-
assert delay == 0.0
|
|
27
|
-
elapsed = time.monotonic() - t0
|
|
28
|
-
assert elapsed < 0.05
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
@pytest.mark.asyncio
|
|
32
|
-
async def
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
assert
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
route_coord
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
await
|
|
84
|
-
|
|
85
|
-
with pytest.raises(
|
|
86
|
-
await
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
import pytest
|
|
4
|
+
from requestflow.ratelimit import GlobalRateLimiter, RateLimitCoordinator, RouteBucketCoordinator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.mark.asyncio
|
|
8
|
+
async def test_global_rate_limiter_validation():
|
|
9
|
+
with pytest.raises(ValueError):
|
|
10
|
+
GlobalRateLimiter(rate=0)
|
|
11
|
+
with pytest.raises(ValueError):
|
|
12
|
+
GlobalRateLimiter(rate=-10)
|
|
13
|
+
with pytest.raises(ValueError):
|
|
14
|
+
GlobalRateLimiter(rate=60.0, ceiling=50.0, allow_custom_ceiling=False)
|
|
15
|
+
limiter = GlobalRateLimiter(
|
|
16
|
+
rate=100.0, ceiling=50.0, allow_custom_ceiling=True)
|
|
17
|
+
assert limiter.rate == 100.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.mark.asyncio
|
|
21
|
+
async def test_interaction_bypass_pacing():
|
|
22
|
+
limiter = GlobalRateLimiter(rate=5.0, burst_allowance=1.0)
|
|
23
|
+
t0 = time.monotonic()
|
|
24
|
+
for _ in range(5):
|
|
25
|
+
delay = await limiter.acquire(is_interaction=True)
|
|
26
|
+
assert delay == 0.0
|
|
27
|
+
elapsed = time.monotonic() - t0
|
|
28
|
+
assert elapsed < 0.05
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.asyncio
|
|
32
|
+
async def test_gcra_burst_tolerance():
|
|
33
|
+
limiter = GlobalRateLimiter(rate=20.0, burst_allowance=3.0)
|
|
34
|
+
delays = []
|
|
35
|
+
for _ in range(4):
|
|
36
|
+
delays.append(await limiter.acquire())
|
|
37
|
+
assert delays == [0.0, 0.0, 0.0, 0.0]
|
|
38
|
+
t0 = time.monotonic()
|
|
39
|
+
d = await limiter.acquire()
|
|
40
|
+
elapsed = time.monotonic() - t0
|
|
41
|
+
assert d > 0.0
|
|
42
|
+
assert elapsed >= 0.03
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.asyncio
|
|
46
|
+
async def test_route_cooldown_independence():
|
|
47
|
+
route_coord = RouteBucketCoordinator()
|
|
48
|
+
route_a = 'POST /channels/1/messages'
|
|
49
|
+
route_b = 'POST /channels/2/messages'
|
|
50
|
+
route_coord.update_route_cooldown(route_a, retry_after=1.0)
|
|
51
|
+
assert route_coord.is_route_cooling_down(route_a) is True
|
|
52
|
+
assert route_coord.is_route_cooling_down(route_b) is False
|
|
53
|
+
assert route_coord.get_route_delay(route_b) == 0.0
|
|
54
|
+
assert route_coord.get_route_delay(route_a) > 0.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.asyncio
|
|
58
|
+
async def test_route_header_update():
|
|
59
|
+
route_coord = RouteBucketCoordinator()
|
|
60
|
+
route = 'GET /guilds/123/members'
|
|
61
|
+
route_coord.update_from_headers(
|
|
62
|
+
route, {'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset-After': '0.5'})
|
|
63
|
+
assert route_coord.is_route_cooling_down(route) is True
|
|
64
|
+
assert route_coord.get_route_delay(route) > 0.0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.asyncio
|
|
68
|
+
async def test_global_429_cooldown():
|
|
69
|
+
limiter = GlobalRateLimiter(rate=40.0)
|
|
70
|
+
limiter.handle_global_429(retry_after=0.1)
|
|
71
|
+
assert limiter.is_cooling_down is True
|
|
72
|
+
t0 = time.monotonic()
|
|
73
|
+
await limiter.acquire()
|
|
74
|
+
elapsed = time.monotonic() - t0
|
|
75
|
+
assert elapsed >= 0.08
|
|
76
|
+
assert limiter.is_cooling_down is False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.asyncio
|
|
80
|
+
async def test_dynamic_rate_update():
|
|
81
|
+
limiter = GlobalRateLimiter(rate=20.0, ceiling=50.0)
|
|
82
|
+
assert limiter.rate == 20.0
|
|
83
|
+
await limiter.update_rate(40.0)
|
|
84
|
+
assert limiter.rate == 40.0
|
|
85
|
+
with pytest.raises(ValueError):
|
|
86
|
+
await limiter.update_rate(60.0)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.asyncio
|
|
90
|
+
async def test_limiter_cancellation_safety():
|
|
91
|
+
limiter = GlobalRateLimiter(rate=2.0, burst_allowance=0.0)
|
|
92
|
+
|
|
93
|
+
async def acquire_task():
|
|
94
|
+
await limiter.acquire()
|
|
95
|
+
await limiter.acquire()
|
|
96
|
+
task = asyncio.create_task(acquire_task())
|
|
97
|
+
await asyncio.sleep(0.01)
|
|
98
|
+
task.cancel()
|
|
99
|
+
with pytest.raises(asyncio.CancelledError):
|
|
100
|
+
await task
|
|
101
|
+
t0 = time.monotonic()
|
|
102
|
+
await limiter.acquire()
|
|
103
|
+
assert time.monotonic() - t0 < 0.6
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|