django-aiogram 4.0.0.dev0__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.
- django_aiogram/__init__.py +64 -0
- django_aiogram/_singleton.py +22 -0
- django_aiogram/admin.py +380 -0
- django_aiogram/api.py +38 -0
- django_aiogram/apps.py +56 -0
- django_aiogram/config/__init__.py +15 -0
- django_aiogram/config/checks.py +759 -0
- django_aiogram/config/defaults.py +102 -0
- django_aiogram/config/enums.py +108 -0
- django_aiogram/config/settings.py +266 -0
- django_aiogram/consumer/__init__.py +13 -0
- django_aiogram/consumer/delivery.py +625 -0
- django_aiogram/consumer/routers.py +19 -0
- django_aiogram/consumer/webhook.py +154 -0
- django_aiogram/context.py +34 -0
- django_aiogram/eventlog/__init__.py +16 -0
- django_aiogram/eventlog/dbrouter.py +55 -0
- django_aiogram/eventlog/events.py +120 -0
- django_aiogram/eventlog/instrumentation.py +231 -0
- django_aiogram/eventlog/recorder.py +922 -0
- django_aiogram/eventlog/signals.py +84 -0
- django_aiogram/eventlog/writer.py +231 -0
- django_aiogram/exceptions.py +60 -0
- django_aiogram/healthcheck.py +412 -0
- django_aiogram/management/__init__.py +1 -0
- django_aiogram/management/commands/__init__.py +1 -0
- django_aiogram/management/commands/start_tgbot.py +308 -0
- django_aiogram/management/commands/tgbot_healthcheck.py +57 -0
- django_aiogram/management/commands/tgbot_prune_events.py +144 -0
- django_aiogram/management/commands/tgbot_reclaim.py +135 -0
- django_aiogram/management/commands/tgbot_webhook.py +87 -0
- django_aiogram/migrations/0001_initial.py +50 -0
- django_aiogram/migrations/0002_kind_id_index.py +32 -0
- django_aiogram/migrations/__init__.py +1 -0
- django_aiogram/models.py +79 -0
- django_aiogram/producer/__init__.py +13 -0
- django_aiogram/producer/client.py +1540 -0
- django_aiogram/producer/throttling.py +336 -0
- django_aiogram/py.typed +0 -0
- django_aiogram/redis.py +394 -0
- django_aiogram/wire/__init__.py +14 -0
- django_aiogram/wire/envelope.py +146 -0
- django_aiogram/wire/payloads.py +195 -0
- django_aiogram/wire/serializers.py +533 -0
- django_aiogram-4.0.0.dev0.dist-info/METADATA +145 -0
- django_aiogram-4.0.0.dev0.dist-info/RECORD +48 -0
- django_aiogram-4.0.0.dev0.dist-info/WHEEL +4 -0
- django_aiogram-4.0.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Pace outgoing calls to stay under Telegram's published limits.
|
|
2
|
+
|
|
3
|
+
Retrying on ``TelegramRetryAfter`` is reactive: the message has already been
|
|
4
|
+
refused and the bot has already been told to back off. These limits are
|
|
5
|
+
documented, so the sane thing is not to exceed them in the first place.
|
|
6
|
+
|
|
7
|
+
Telegram enforces three at once:
|
|
8
|
+
|
|
9
|
+
* roughly 30 messages per second overall
|
|
10
|
+
* about one message per second to the same chat
|
|
11
|
+
* 20 messages per minute to the same group or channel
|
|
12
|
+
|
|
13
|
+
A limiter belongs to one bot. Limits are per token, so a second bot must not
|
|
14
|
+
share this budget — which is what makes the multi-bot case work unchanged.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from collections import OrderedDict
|
|
21
|
+
from collections.abc import Awaitable, Callable
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
25
|
+
from django.core.signals import setting_changed
|
|
26
|
+
|
|
27
|
+
from django_aiogram.config.defaults import DEFAULTS
|
|
28
|
+
from django_aiogram.config.enums import KNOWN_RATE_LIMIT_KEYS, RateLimitKey
|
|
29
|
+
from django_aiogram.config.settings import SETTINGS_NAME, conf
|
|
30
|
+
|
|
31
|
+
Clock = Callable[[], float]
|
|
32
|
+
Sleeper = Callable[[float], Awaitable[None]]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# the shipped limits live in defaults.py; duplicating them here would drift
|
|
36
|
+
RATE_LIMIT_DEFAULTS: dict[str, float] = dict(DEFAULTS['RATE_LIMIT'])
|
|
37
|
+
|
|
38
|
+
# chats a bot talks to at once; beyond this the idle ones are dropped
|
|
39
|
+
MAX_TRACKED_CHATS = 4096
|
|
40
|
+
#: how many of the oldest buckets to look at before evicting one regardless
|
|
41
|
+
EVICTION_CANDIDATES = 8
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TokenBucket:
|
|
45
|
+
"""A token bucket by its behavior, GCRA by its implementation.
|
|
46
|
+
|
|
47
|
+
The name is kept because that is what the limits are described as, but nothing
|
|
48
|
+
counts tokens. Each caller claims the next free slot under the lock and sleeps
|
|
49
|
+
until exactly that instant, which gives the same pacing for two reasons a
|
|
50
|
+
counter cannot:
|
|
51
|
+
|
|
52
|
+
* **Wakeups are O(1) per admitted call.** Counting meant every waiter computed
|
|
53
|
+
the same wait from the same shared state, so N waiters woke together, one
|
|
54
|
+
won and N-1 recomputed — about N²/2 wakeups. Measured here, on the design that
|
|
55
|
+
ships: 35 wakeups for 40 queued sends and 495 for 500, which is one per send that
|
|
56
|
+
*had to wait* — the burst goes through without sleeping at all, so the count is
|
|
57
|
+
``max(0, N - max(1, floor(capacity)))`` rather than ``N`` — clamped, because a
|
|
58
|
+
batch smaller than the burst sleeps not at all: measured, three calls against a
|
|
59
|
+
capacity of five wake nobody. The floor matters because
|
|
60
|
+
``capacity`` is a float: measured, 1.5 admits one call without sleeping and 5.5
|
|
61
|
+
admits five, so a fraction of a slot buys nothing. The ``max`` matters because a
|
|
62
|
+
capacity *below* one still admits the first call — ``_burst`` clamps to zero and the
|
|
63
|
+
claim starts at ``now`` — so 0.5 gives 39 wakeups for 40 calls, not 40.
|
|
64
|
+
|
|
65
|
+
The old shape is quoted as N²/2 rather than as a number, because it is gone and a
|
|
66
|
+
number for it would be invented.
|
|
67
|
+
* **Admission is strict FIFO.** A herd re-racing for the same token admits in
|
|
68
|
+
whatever order the loop happens to resume, so the message that waited
|
|
69
|
+
longest had no claim on going first.
|
|
70
|
+
|
|
71
|
+
It also removes the reason `TOKEN_EPSILON` existed: refilling accumulated
|
|
72
|
+
float error, a full bucket landed on 0.9999999999, and the wait shrank to
|
|
73
|
+
intervals too small to advance the clock at all. There is no loop to spin in
|
|
74
|
+
here — a slot is claimed once and waited for once.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
rate: float,
|
|
80
|
+
capacity: float | None = None,
|
|
81
|
+
*,
|
|
82
|
+
clock: Clock = time.monotonic,
|
|
83
|
+
sleep: Sleeper = asyncio.sleep,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Build a bucket admitting ``rate`` calls per second, ``capacity`` at once."""
|
|
86
|
+
if rate <= 0:
|
|
87
|
+
msg = 'rate must be positive'
|
|
88
|
+
raise ValueError(msg)
|
|
89
|
+
self.rate = rate
|
|
90
|
+
self.capacity = capacity if capacity is not None else max(rate, 1.0)
|
|
91
|
+
self._clock = clock
|
|
92
|
+
self._sleep = sleep
|
|
93
|
+
self._interval = 1 / rate
|
|
94
|
+
# (capacity - 1), not capacity: the first call takes a slot rather than
|
|
95
|
+
# only credit for one, so the full form admits capacity + 1 in a burst
|
|
96
|
+
self._burst = max(0.0, (self.capacity - 1) * self._interval)
|
|
97
|
+
# a fresh bucket owes nothing, which is the whole burst available at once
|
|
98
|
+
self._next_free = clock() - self._burst
|
|
99
|
+
self._guard = threading.Lock()
|
|
100
|
+
|
|
101
|
+
def is_idle(self) -> bool:
|
|
102
|
+
"""Report whether the bucket owes no wait, and so is free to forget.
|
|
103
|
+
|
|
104
|
+
Against `now - burst` rather than `now`: a bucket that has spent part of
|
|
105
|
+
its burst still owes that part, and calling it idle would let `_evict`
|
|
106
|
+
forget a chat that is mid-conversation — which is the bounded-loss
|
|
107
|
+
argument that method rests on.
|
|
108
|
+
"""
|
|
109
|
+
with self._guard:
|
|
110
|
+
return self._next_free <= self._clock() - self._burst
|
|
111
|
+
|
|
112
|
+
async def acquire(self) -> None:
|
|
113
|
+
"""Claim the next free slot, then wait until it arrives.
|
|
114
|
+
|
|
115
|
+
The guard is a threading lock, not an asyncio one: a limiter is shared
|
|
116
|
+
per token and may be reached from more than one loop or thread, and an
|
|
117
|
+
asyncio primitive binds itself to the first loop that awaits it. It is
|
|
118
|
+
held only across the claim, never across the sleep.
|
|
119
|
+
"""
|
|
120
|
+
with self._guard:
|
|
121
|
+
now = self._clock()
|
|
122
|
+
# the claim cannot start further back than the burst allows, or an
|
|
123
|
+
# idle bucket would bank credit without limit
|
|
124
|
+
slot = max(self._next_free, now - self._burst)
|
|
125
|
+
self._next_free = slot + self._interval
|
|
126
|
+
# read again, outside the lock: `now` was sampled while claiming, and
|
|
127
|
+
# anything between then and here — the GIL, another thread, a slow
|
|
128
|
+
# logger — makes it stale. Sleeping `slot - stale` overshoots the slot by
|
|
129
|
+
# exactly that gap, which is throttling nobody asked for
|
|
130
|
+
wait = slot - self._clock()
|
|
131
|
+
if wait > 0:
|
|
132
|
+
await self._sleep(wait)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class RateLimiter:
|
|
136
|
+
"""Holds the three buckets Telegram applies to a single bot."""
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
overall_per_second: float = RATE_LIMIT_DEFAULTS[RateLimitKey.OVERALL_PER_SECOND.value],
|
|
141
|
+
per_chat_per_second: float = RATE_LIMIT_DEFAULTS[RateLimitKey.PER_CHAT_PER_SECOND.value],
|
|
142
|
+
group_per_minute: float = RATE_LIMIT_DEFAULTS[RateLimitKey.GROUP_PER_MINUTE.value],
|
|
143
|
+
*,
|
|
144
|
+
clock: Clock = time.monotonic,
|
|
145
|
+
sleep: Sleeper = asyncio.sleep,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Build the buckets for one bot; a rate of 0 switches that budget off.
|
|
148
|
+
|
|
149
|
+
The parameter names are the ``RATE_LIMIT`` keys, so a settings mapping
|
|
150
|
+
can be splatted straight in.
|
|
151
|
+
"""
|
|
152
|
+
self._clock = clock
|
|
153
|
+
self._sleep = sleep
|
|
154
|
+
self._overall = self._bucket(overall_per_second)
|
|
155
|
+
self._per_chat_rate = per_chat_per_second
|
|
156
|
+
self._group_rate = group_per_minute / 60 if group_per_minute else 0
|
|
157
|
+
self._group_capacity = group_per_minute or None
|
|
158
|
+
self._chats: OrderedDict[int, TokenBucket] = OrderedDict()
|
|
159
|
+
self._groups: OrderedDict[int, TokenBucket] = OrderedDict()
|
|
160
|
+
# threading, not asyncio: see TokenBucket.acquire
|
|
161
|
+
self._lock = threading.Lock()
|
|
162
|
+
|
|
163
|
+
def _bucket(self, rate: float, capacity: float | None = None) -> TokenBucket | None:
|
|
164
|
+
"""Build a bucket, or None when this limit is switched off.
|
|
165
|
+
|
|
166
|
+
``None`` for a zero rate is what lets every caller treat *unlimited* as *no
|
|
167
|
+
bucket* and skip the wait entirely, rather than each one repeating the check
|
|
168
|
+
against the setting.
|
|
169
|
+
"""
|
|
170
|
+
if not rate:
|
|
171
|
+
return None
|
|
172
|
+
return TokenBucket(rate, capacity, clock=self._clock, sleep=self._sleep)
|
|
173
|
+
|
|
174
|
+
def _for(
|
|
175
|
+
self,
|
|
176
|
+
chats: OrderedDict[int, TokenBucket],
|
|
177
|
+
key: int,
|
|
178
|
+
rate: float,
|
|
179
|
+
capacity: float | None = None,
|
|
180
|
+
) -> TokenBucket | None:
|
|
181
|
+
"""Return this key's bucket, creating it on first use, and mark it as used.
|
|
182
|
+
|
|
183
|
+
The ``move_to_end`` is the whole reason this is one method rather than a
|
|
184
|
+
``setdefault``: the map is bounded, and eviction draws its candidates from the
|
|
185
|
+
least recently used end — so a lookup that did not record itself would leave a
|
|
186
|
+
busy chat sitting at the front of that queue, and be thrown away while it is
|
|
187
|
+
still owed a wait.
|
|
188
|
+
"""
|
|
189
|
+
bucket = chats.get(key)
|
|
190
|
+
if bucket is None:
|
|
191
|
+
created = self._bucket(rate, capacity)
|
|
192
|
+
if created is None:
|
|
193
|
+
return None
|
|
194
|
+
bucket = chats[key] = created
|
|
195
|
+
self._evict(chats)
|
|
196
|
+
chats.move_to_end(key)
|
|
197
|
+
return bucket
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _evict(chats: OrderedDict[int, TokenBucket]) -> None:
|
|
201
|
+
"""Keep the map at the cap, preferring buckets that owe no wait time.
|
|
202
|
+
|
|
203
|
+
When every candidate is still busy the least recently used one goes
|
|
204
|
+
anyway, and its debt goes with it. That is a bounded loss rather than a
|
|
205
|
+
way around the limit: a bucket is only evicted once MAX_TRACKED_CHATS
|
|
206
|
+
other chats have been more recently active, which at the overall limit
|
|
207
|
+
takes minutes, while per-chat debt clears in about a second. The
|
|
208
|
+
alternative is an unbounded map, which is a leak.
|
|
209
|
+
"""
|
|
210
|
+
while len(chats) > MAX_TRACKED_CHATS:
|
|
211
|
+
# stopping at the first busy bucket left the map uncapped: one chat
|
|
212
|
+
# that keeps sending pinned everything behind it
|
|
213
|
+
candidates = [chats.popitem(last=False) for _ in range(min(EVICTION_CANDIDATES, len(chats)))]
|
|
214
|
+
evict = next((index for index, (_, bucket) in enumerate(candidates) if bucket.is_idle()), 0)
|
|
215
|
+
del candidates[evict]
|
|
216
|
+
for key, bucket in reversed(candidates):
|
|
217
|
+
chats[key] = bucket
|
|
218
|
+
chats.move_to_end(key, last=False)
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def is_group(chat_id: int) -> bool:
|
|
222
|
+
"""Report whether ``chat_id`` is a group: those all carry a negative id.
|
|
223
|
+
|
|
224
|
+
Supergroups and channels count as groups here, since Telegram gives the
|
|
225
|
+
three of them the same per-minute budget.
|
|
226
|
+
"""
|
|
227
|
+
return chat_id < 0
|
|
228
|
+
|
|
229
|
+
async def acquire(self, chat_id: int | str | None = None) -> None:
|
|
230
|
+
"""Wait until sending to ``chat_id`` stays inside every limit."""
|
|
231
|
+
buckets: list[TokenBucket] = []
|
|
232
|
+
if self._overall is not None:
|
|
233
|
+
buckets.append(self._overall)
|
|
234
|
+
|
|
235
|
+
key = self._chat_key(chat_id)
|
|
236
|
+
if key is not None:
|
|
237
|
+
with self._lock:
|
|
238
|
+
per_chat = self._for(self._chats, key, self._per_chat_rate)
|
|
239
|
+
group = (
|
|
240
|
+
self._for(self._groups, key, self._group_rate, self._group_capacity) if self.is_group(key) else None
|
|
241
|
+
)
|
|
242
|
+
buckets.extend(bucket for bucket in (per_chat, group) if bucket is not None)
|
|
243
|
+
|
|
244
|
+
for bucket in buckets:
|
|
245
|
+
await bucket.acquire()
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _chat_key(chat_id: int | str | None) -> int | None:
|
|
249
|
+
"""Return the bucket key for ``chat_id``, or None when it has none.
|
|
250
|
+
|
|
251
|
+
Per-chat limits only apply to numeric ids: an ``@channel`` name cannot
|
|
252
|
+
be keyed. The runtime check is wider than the annotation because the id
|
|
253
|
+
comes from caller kwargs, where anything at all can turn up — including
|
|
254
|
+
a bool, which int() would otherwise fold into chat 1.
|
|
255
|
+
"""
|
|
256
|
+
if isinstance(chat_id, bool) or not isinstance(chat_id, (int, str)):
|
|
257
|
+
return None
|
|
258
|
+
try:
|
|
259
|
+
return int(chat_id)
|
|
260
|
+
except (TypeError, ValueError):
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def build_rate_limiter() -> RateLimiter | None:
|
|
265
|
+
"""Build the limiter described by settings, or None when disabled."""
|
|
266
|
+
limits = conf['RATE_LIMIT']
|
|
267
|
+
if not limits:
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
unknown = sorted(str(key) for key in limits if key not in KNOWN_RATE_LIMIT_KEYS)
|
|
271
|
+
if unknown:
|
|
272
|
+
msg = f"{SETTINGS_NAME}['RATE_LIMIT'] has unknown keys: {', '.join(unknown)}."
|
|
273
|
+
raise ImproperlyConfigured(msg)
|
|
274
|
+
return RateLimiter(**limits)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class _LimiterRegistry:
|
|
278
|
+
"""The limiters in use, one per bot token.
|
|
279
|
+
|
|
280
|
+
Telegram applies its limits per bot, so two ``TelegramBot`` objects holding
|
|
281
|
+
the same token must draw on one budget; separate limiters would let them
|
|
282
|
+
send at twice the rate.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
def __init__(self) -> None:
|
|
286
|
+
"""Start empty: a limiter is built on the first send with that token."""
|
|
287
|
+
self._limiters: dict[str, RateLimiter] = {}
|
|
288
|
+
# threading, not asyncio: see TokenBucket.acquire
|
|
289
|
+
self._guard = threading.Lock()
|
|
290
|
+
|
|
291
|
+
def get(self, token: str) -> RateLimiter | None:
|
|
292
|
+
"""Return the limiter for ``token``, building it if this is the first ask."""
|
|
293
|
+
with self._guard:
|
|
294
|
+
existing = self._limiters.get(token)
|
|
295
|
+
if existing is not None:
|
|
296
|
+
return existing
|
|
297
|
+
limiter = build_rate_limiter()
|
|
298
|
+
if limiter is not None:
|
|
299
|
+
self._limiters[token] = limiter
|
|
300
|
+
return limiter
|
|
301
|
+
|
|
302
|
+
def clear(self) -> None:
|
|
303
|
+
"""Forget every limiter, so the next ask reads the settings again."""
|
|
304
|
+
with self._guard:
|
|
305
|
+
self._limiters.clear()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
_registry = _LimiterRegistry()
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def get_rate_limiter(token: str) -> RateLimiter | None:
|
|
312
|
+
"""Return the limiter for ``token``, shared across bot instances."""
|
|
313
|
+
return _registry.get(token)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def reset_rate_limiters() -> None:
|
|
317
|
+
"""Forget the shared limiters, so changed settings take effect."""
|
|
318
|
+
_registry.clear()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _reset_on_setting_change(
|
|
322
|
+
sender: object, # noqa: ARG001 - Django sends this to every receiver, named
|
|
323
|
+
setting: str,
|
|
324
|
+
**kwargs: Any,
|
|
325
|
+
) -> None:
|
|
326
|
+
"""Forget the shared limiters when the setting they were built from changes.
|
|
327
|
+
|
|
328
|
+
The registry is keyed by token and outlives any one bot, so without this a test or a
|
|
329
|
+
runtime change of the rates would keep pacing against the numbers a previous
|
|
330
|
+
configuration was built with.
|
|
331
|
+
"""
|
|
332
|
+
if setting == SETTINGS_NAME:
|
|
333
|
+
reset_rate_limiters()
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
setting_changed.connect(_reset_on_setting_change, dispatch_uid='django_aiogram.producer.throttling')
|
django_aiogram/py.typed
ADDED
|
File without changes
|