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.
Files changed (48) hide show
  1. django_aiogram/__init__.py +64 -0
  2. django_aiogram/_singleton.py +22 -0
  3. django_aiogram/admin.py +380 -0
  4. django_aiogram/api.py +38 -0
  5. django_aiogram/apps.py +56 -0
  6. django_aiogram/config/__init__.py +15 -0
  7. django_aiogram/config/checks.py +759 -0
  8. django_aiogram/config/defaults.py +102 -0
  9. django_aiogram/config/enums.py +108 -0
  10. django_aiogram/config/settings.py +266 -0
  11. django_aiogram/consumer/__init__.py +13 -0
  12. django_aiogram/consumer/delivery.py +625 -0
  13. django_aiogram/consumer/routers.py +19 -0
  14. django_aiogram/consumer/webhook.py +154 -0
  15. django_aiogram/context.py +34 -0
  16. django_aiogram/eventlog/__init__.py +16 -0
  17. django_aiogram/eventlog/dbrouter.py +55 -0
  18. django_aiogram/eventlog/events.py +120 -0
  19. django_aiogram/eventlog/instrumentation.py +231 -0
  20. django_aiogram/eventlog/recorder.py +922 -0
  21. django_aiogram/eventlog/signals.py +84 -0
  22. django_aiogram/eventlog/writer.py +231 -0
  23. django_aiogram/exceptions.py +60 -0
  24. django_aiogram/healthcheck.py +412 -0
  25. django_aiogram/management/__init__.py +1 -0
  26. django_aiogram/management/commands/__init__.py +1 -0
  27. django_aiogram/management/commands/start_tgbot.py +308 -0
  28. django_aiogram/management/commands/tgbot_healthcheck.py +57 -0
  29. django_aiogram/management/commands/tgbot_prune_events.py +144 -0
  30. django_aiogram/management/commands/tgbot_reclaim.py +135 -0
  31. django_aiogram/management/commands/tgbot_webhook.py +87 -0
  32. django_aiogram/migrations/0001_initial.py +50 -0
  33. django_aiogram/migrations/0002_kind_id_index.py +32 -0
  34. django_aiogram/migrations/__init__.py +1 -0
  35. django_aiogram/models.py +79 -0
  36. django_aiogram/producer/__init__.py +13 -0
  37. django_aiogram/producer/client.py +1540 -0
  38. django_aiogram/producer/throttling.py +336 -0
  39. django_aiogram/py.typed +0 -0
  40. django_aiogram/redis.py +394 -0
  41. django_aiogram/wire/__init__.py +14 -0
  42. django_aiogram/wire/envelope.py +146 -0
  43. django_aiogram/wire/payloads.py +195 -0
  44. django_aiogram/wire/serializers.py +533 -0
  45. django_aiogram-4.0.0.dev0.dist-info/METADATA +145 -0
  46. django_aiogram-4.0.0.dev0.dist-info/RECORD +48 -0
  47. django_aiogram-4.0.0.dev0.dist-info/WHEEL +4 -0
  48. 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')
File without changes