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.
Files changed (36) hide show
  1. {requestflow-0.1.0/src/requestflow.egg-info → requestflow-0.1.1}/PKG-INFO +1 -1
  2. {requestflow-0.1.0 → requestflow-0.1.1}/pyproject.toml +73 -73
  3. {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/__init__.py +1 -1
  4. {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/flow.py +1 -1
  5. {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/integrations/discord_py.py +209 -115
  6. {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/models.py +2 -2
  7. {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/queue.py +6 -6
  8. {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/ratelimit.py +11 -5
  9. {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/scheduler.py +2 -2
  10. {requestflow-0.1.0 → requestflow-0.1.1/src/requestflow.egg-info}/PKG-INFO +1 -1
  11. {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_discord_integration.py +98 -66
  12. {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_ratelimit.py +103 -89
  13. {requestflow-0.1.0 → requestflow-0.1.1}/LICENSE +0 -0
  14. {requestflow-0.1.0 → requestflow-0.1.1}/README.md +0 -0
  15. {requestflow-0.1.0 → requestflow-0.1.1}/setup.cfg +0 -0
  16. {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/deduplication.py +0 -0
  17. {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/exceptions.py +0 -0
  18. {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/executor.py +0 -0
  19. {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/integrations/__init__.py +0 -0
  20. {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/metrics.py +0 -0
  21. {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/policies.py +0 -0
  22. {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/priorities.py +0 -0
  23. {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow/py.typed +0 -0
  24. {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow.egg-info/SOURCES.txt +0 -0
  25. {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow.egg-info/dependency_links.txt +0 -0
  26. {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow.egg-info/requires.txt +0 -0
  27. {requestflow-0.1.0 → requestflow-0.1.1}/src/requestflow.egg-info/top_level.txt +0 -0
  28. {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_burst_stress.py +0 -0
  29. {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_cancellation.py +0 -0
  30. {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_deduplication.py +0 -0
  31. {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_executor.py +0 -0
  32. {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_fairness.py +0 -0
  33. {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_lifecycle.py +0 -0
  34. {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_metrics.py +0 -0
  35. {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_priorities.py +0 -0
  36. {requestflow-0.1.0 → requestflow-0.1.1}/tests/test_queue.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: requestflow
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: stop getting 429 ratelimited like an amateur. high-performance http scheduling and pacing for discord.py.
5
5
  Author: requestflow
6
6
  License-Expression: MIT
@@ -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.0"
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.0'
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
- 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
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
- 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)
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: requestflow
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: stop getting 429 ratelimited like an amateur. high-performance http scheduling and pacing for discord.py.
5
5
  Author: requestflow
6
6
  License-Expression: MIT
@@ -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
- @pytest.mark.asyncio
26
- async def test_transparent_adapter_mode_b():
27
- async with RequestFlow() as flow:
28
- bot = MockBot()
29
- original_request = bot.http.request
30
- original_request.return_value = {'id': '12345', 'content': 'hello'}
31
- flow.attach(bot)
32
- assert bot.http.request != original_request
33
- route = Route(
34
- 'POST', '/channels/{channel_id}/messages', channel_id=98765)
35
- result = await bot.http.request(route, json={'content': 'hello'})
36
- assert result == {'id': '12345', 'content': 'hello'}
37
- original_request.assert_called_once()
38
- assert flow.stats.submitted == 1
39
- assert flow.stats.completed == 1
40
- flow.detach(bot)
41
- assert bot.http.request == original_request
42
-
43
-
44
- @pytest.mark.asyncio
45
- async def test_interaction_endpoint_critical_priority():
46
- async with RequestFlow() as flow:
47
- bot = MockBot()
48
- bot.http.request.return_value = {'type': 4}
49
- flow.attach(bot)
50
- route = Route(
51
- 'POST', '/interactions/{interaction_id}/{interaction_token}/callback', interaction_id=111, interaction_token='xyz')
52
- res = await bot.http.request(route, json={'type': 4})
53
- assert res == {'type': 4}
54
- assert flow.stats.submitted == 1
55
- assert flow.stats.completed == 1
56
-
57
-
58
- @pytest.mark.asyncio
59
- async def test_managed_mode_a_with_discord_call():
60
- async with RequestFlow() as flow:
61
- mock_send = AsyncMock(
62
- return_value={'id': 1, 'content': 'managed message'})
63
- res = await flow.submit(mock_send, content='managed message', priority=Priority.HIGH, guild_id=123456)
64
- assert res == {'id': 1, 'content': 'managed message'}
65
- mock_send.assert_called_once_with(content='managed message')
66
- assert flow.stats.completed == 1
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 test_route_cooldown_independence():
33
- route_coord = RouteBucketCoordinator()
34
- route_a = 'POST /channels/1/messages'
35
- route_b = 'POST /channels/2/messages'
36
- route_coord.update_route_cooldown(route_a, retry_after=1.0)
37
- assert route_coord.is_route_cooling_down(route_a) is True
38
- assert route_coord.is_route_cooling_down(route_b) is False
39
- assert route_coord.get_route_delay(route_b) == 0.0
40
- assert route_coord.get_route_delay(route_a) > 0.0
41
-
42
-
43
- @pytest.mark.asyncio
44
- async def test_route_header_update():
45
- route_coord = RouteBucketCoordinator()
46
- route = 'GET /guilds/123/members'
47
- route_coord.update_from_headers(
48
- route, {'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset-After': '0.5'})
49
- assert route_coord.is_route_cooling_down(route) is True
50
- assert route_coord.get_route_delay(route) > 0.0
51
-
52
-
53
- @pytest.mark.asyncio
54
- async def test_global_429_cooldown():
55
- limiter = GlobalRateLimiter(rate=40.0)
56
- limiter.handle_global_429(retry_after=0.1)
57
- assert limiter.is_cooling_down is True
58
- t0 = time.monotonic()
59
- await limiter.acquire()
60
- elapsed = time.monotonic() - t0
61
- assert elapsed >= 0.08
62
- assert limiter.is_cooling_down is False
63
-
64
-
65
- @pytest.mark.asyncio
66
- async def test_dynamic_rate_update():
67
- limiter = GlobalRateLimiter(rate=20.0, ceiling=50.0)
68
- assert limiter.rate == 20.0
69
- await limiter.update_rate(40.0)
70
- assert limiter.rate == 40.0
71
- with pytest.raises(ValueError):
72
- await limiter.update_rate(60.0)
73
-
74
-
75
- @pytest.mark.asyncio
76
- async def test_limiter_cancellation_safety():
77
- limiter = GlobalRateLimiter(rate=2.0)
78
-
79
- async def acquire_task():
80
- await limiter.acquire()
81
- await limiter.acquire()
82
- task = asyncio.create_task(acquire_task())
83
- await asyncio.sleep(0.01)
84
- task.cancel()
85
- with pytest.raises(asyncio.CancelledError):
86
- await task
87
- t0 = time.monotonic()
88
- await limiter.acquire()
89
- assert time.monotonic() - t0 < 0.6
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