funora 0.0.1.dev2__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 (87) hide show
  1. funora/__init__.py +265 -0
  2. funora/_account.py +661 -0
  3. funora/_aclient.py +1484 -0
  4. funora/_budget.py +579 -0
  5. funora/_calc.py +182 -0
  6. funora/_canonical.py +235 -0
  7. funora/_catalog.py +473 -0
  8. funora/_chat_history.py +367 -0
  9. funora/_chats.py +392 -0
  10. funora/_chips.py +403 -0
  11. funora/_classify.py +466 -0
  12. funora/_client.py +1490 -0
  13. funora/_currency_switch.py +130 -0
  14. funora/_cursor.py +120 -0
  15. funora/_delivered.py +233 -0
  16. funora/_diff.py +752 -0
  17. funora/_engine.py +4795 -0
  18. funora/_extract.py +124 -0
  19. funora/_field_schema.py +112 -0
  20. funora/_fileio.py +58 -0
  21. funora/_gate.py +69 -0
  22. funora/_hops.py +101 -0
  23. funora/_host.py +120 -0
  24. funora/_identity.py +284 -0
  25. funora/_json.py +31 -0
  26. funora/_listen.py +312 -0
  27. funora/_lot_form.py +331 -0
  28. funora/_market.py +406 -0
  29. funora/_matching.py +147 -0
  30. funora/_money.py +279 -0
  31. funora/_monitoring.py +396 -0
  32. funora/_observed.py +237 -0
  33. funora/_order.py +552 -0
  34. funora/_order_details.py +281 -0
  35. funora/_orders.py +807 -0
  36. funora/_outbound.py +453 -0
  37. funora/_own_lots.py +301 -0
  38. funora/_poll.py +410 -0
  39. funora/_price_audit.py +281 -0
  40. funora/_proxies.py +232 -0
  41. funora/_raise.py +150 -0
  42. funora/_refund.py +102 -0
  43. funora/_result.py +157 -0
  44. funora/_retry.py +213 -0
  45. funora/_review_write.py +138 -0
  46. funora/_reviews.py +584 -0
  47. funora/_runner.py +651 -0
  48. funora/_secret.py +385 -0
  49. funora/_showcase.py +362 -0
  50. funora/_signals.py +375 -0
  51. funora/_skeleton.py +752 -0
  52. funora/_snapshot.py +276 -0
  53. funora/_state.py +279 -0
  54. funora/_stock.py +32 -0
  55. funora/_thread.py +574 -0
  56. funora/_transport.py +1023 -0
  57. funora/_updates.py +292 -0
  58. funora/_verdicts.py +91 -0
  59. funora/_viewing.py +143 -0
  60. funora/_watch.py +825 -0
  61. funora/_watch_state.py +237 -0
  62. funora/_whoami.py +546 -0
  63. funora/bot/__init__.py +52 -0
  64. funora/bot/_delivery.py +341 -0
  65. funora/bot/_outbox.py +261 -0
  66. funora/bot/_runtime.py +447 -0
  67. funora/bot/_spool.py +534 -0
  68. funora/budget.py +311 -0
  69. funora/capabilities.py +297 -0
  70. funora/conformance.py +758 -0
  71. funora/contract.py +73 -0
  72. funora/errors.py +996 -0
  73. funora/events.py +189 -0
  74. funora/extraction.py +420 -0
  75. funora/observe.py +560 -0
  76. funora/operations.py +671 -0
  77. funora/py.typed +0 -0
  78. funora/reconciliation.py +51 -0
  79. funora/response_classes.py +155 -0
  80. funora/retry.py +238 -0
  81. funora/send_outcome.py +78 -0
  82. funora/skeleton_format.py +77 -0
  83. funora-0.0.1.dev2.dist-info/METADATA +294 -0
  84. funora-0.0.1.dev2.dist-info/RECORD +87 -0
  85. funora-0.0.1.dev2.dist-info/WHEEL +4 -0
  86. funora-0.0.1.dev2.dist-info/entry_points.txt +2 -0
  87. funora-0.0.1.dev2.dist-info/licenses/LICENSE +201 -0
funora/_budget.py ADDED
@@ -0,0 +1,579 @@
1
+ """Бюджет исходящих запросов.
2
+
3
+ Модуль не спит и не смотрит на часы: время передаётся снаружи. Иначе бюджет
4
+ пришлось бы проверять настоящими секундами, а проверка, идущая минуту, живёт
5
+ ровно до первого раза, когда она мешает.
6
+
7
+ Вёдра вложены, и порядок расхода нормативен: сначала общее ведро сетевой
8
+ идентичности, затем ведро аккаунта. Обратный порядок обходится тривиально -
9
+ десять аккаунтов в одном процессе уложились бы в свои личные пределы и вместе
10
+ превысили бы общий, а площадка видит именно общий: ей видна пара из исходящего
11
+ адреса и хоста, а не то, сколько логических аккаунтов мы завели у себя.
12
+
13
+ Расходуются отправленные запросы, а не логические операции. Повтор и переход по
14
+ редиректу - тоже запросы. Считать иначе означало бы сделать шторм повторов
15
+ бесплатным ровно в тот момент, когда площадке хуже всего.
16
+
17
+ Числа взяты из спецификации и помечены там провизорными. Измерять настоящие
18
+ пороги нельзя: измерение означало бы намеренное превышение.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Iterator
24
+ from contextlib import contextmanager
25
+ from dataclasses import dataclass, field
26
+ from fractions import Fraction
27
+ from threading import RLock
28
+ from typing import Final
29
+
30
+ from ._monitoring import MarketWatch, MonitoringLimit, MonitoringPlan, validate_watches
31
+ from .budget import (
32
+ BUCKETS,
33
+ BURST_WINDOW_MS,
34
+ DEMAND_WINDOW_MS,
35
+ FLOOR_SHARE,
36
+ MAX_WAIT_MS,
37
+ ON_REFUSAL,
38
+ WAIT_GUARD_MS,
39
+ BucketLimits,
40
+ RequestClass,
41
+ )
42
+ from .errors import BudgetExhaustedError
43
+
44
+ __all__ = ["TokenBucket", "Budget", "Reservation"]
45
+
46
+
47
+ def _exact(value: float | Fraction) -> Fraction:
48
+ """Читает десятичное значение без переноса двоичной погрешности в запас."""
49
+ return value if isinstance(value, Fraction) else Fraction(str(value))
50
+
51
+
52
+ def wait_until_ms(now: float | Fraction, until: float | Fraction) -> int:
53
+ """Округляет положительную паузу по общему правилу бюджета."""
54
+ remaining = _exact(until) - _exact(now)
55
+ return int(remaining * 1000) + WAIT_GUARD_MS if remaining > 0 else 0
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class Reservation:
60
+ """Результат попытки занять бюджет.
61
+
62
+ Attributes:
63
+ granted (bool): Выдан ли бюджет.
64
+ wait_ms (int): Сколько ждать до следующей попытки. Ноль, если выдан.
65
+ bucket (str): Имя ведра, которое отказало. Пустая строка, если выдан.
66
+ """
67
+
68
+ granted: bool
69
+ wait_ms: int
70
+ bucket: str
71
+
72
+
73
+ @dataclass
74
+ class TokenBucket:
75
+ """Ведро с восполняемым запасом запросов.
76
+
77
+ Args:
78
+ limits (BucketLimits): Ёмкость и скорость пополнения.
79
+ tokens (float): Текущий запас. По умолчанию ведро полное.
80
+ updated_at (float): Момент последнего пополнения, монотонные секунды.
81
+ factor (float): Доля от объявленной ёмкости. Меньше единицы после
82
+ ограничения частоты: площадка сказала «слишком быстро», и ёмкость
83
+ урезана до тех пор, пока не наберётся успешных запросов подряд.
84
+ """
85
+
86
+ limits: BucketLimits
87
+ tokens: float | Fraction = field(default=-1.0)
88
+ updated_at: float | Fraction = 0.0
89
+ factor: float | Fraction = 1.0
90
+ _refill_rate: Fraction = field(init=False, repr=False)
91
+ _burst_rate: Fraction = field(init=False, repr=False)
92
+
93
+ #: Право на залп: сколько ещё можно отправить, не переводя дыхания.
94
+ #:
95
+ #: Второй предел, независимый от запаса. Ведро, полное до краёв, всё равно
96
+ #: не выпустит больше burst запросов подряд: запас копится в простое, а
97
+ #: право на залп восстанавливается равномерно, burst единиц за окно.
98
+ allowance: float | Fraction = 0.0
99
+
100
+ def __post_init__(self) -> None:
101
+ """Заполняет ведро, если начальный запас не задан.
102
+
103
+ Returns:
104
+ None
105
+ """
106
+ if self.tokens < 0:
107
+ self.tokens = self.limits.capacity
108
+ if self.allowance == 0.0:
109
+ self.allowance = self.limits.burst
110
+ self.tokens = _exact(self.tokens)
111
+ self.allowance = _exact(self.allowance)
112
+ self.updated_at = _exact(self.updated_at)
113
+ self.factor = _exact(self.factor)
114
+ self._refill_rate = _exact(self.limits.refill_per_second)
115
+ self._burst_rate = Fraction(self.limits.burst * 1000, BURST_WINDOW_MS)
116
+
117
+ def _refill(self, now: float | Fraction) -> None:
118
+ """Пополняет ведро по прошедшему времени.
119
+
120
+ Args:
121
+ now (float): Текущий момент, монотонные секунды.
122
+
123
+ Returns:
124
+ None
125
+ """
126
+ moment = _exact(now)
127
+ if moment <= self.updated_at:
128
+ # Монотонные часы назад не идут, но защита дешевле разбирательства:
129
+ # отрицательный интервал молча выдал бы бесконечный бюджет.
130
+ return
131
+ elapsed = moment - _exact(self.updated_at)
132
+ self.tokens = min(
133
+ self.limits.capacity * _exact(self.factor),
134
+ _exact(self.tokens) + elapsed * self._refill_rate,
135
+ )
136
+ self.allowance = min(
137
+ Fraction(self.limits.burst),
138
+ _exact(self.allowance) + elapsed * self._burst_rate,
139
+ )
140
+ self.updated_at = moment
141
+
142
+ def scale(self, factor: float | Fraction) -> None:
143
+ """Урезает ёмкость ведра до доли от объявленной.
144
+
145
+ Запас подрезается вместе с ёмкостью: ведро, полное по прежней мерке, при
146
+ уменьшенной ёмкости отдало бы залпом больше, чем новая ёмкость
147
+ позволяет, - то есть урезание не подействовало бы до первого исчерпания.
148
+
149
+ Args:
150
+ factor (float): Доля от объявленной ёмкости.
151
+
152
+ Returns:
153
+ None
154
+ """
155
+ exact_factor = _exact(factor)
156
+ if not 0 < exact_factor <= 1:
157
+ raise ValueError("доля ёмкости должна быть в границах (0, 1]")
158
+ self.factor = exact_factor
159
+ ceiling = self.limits.capacity * exact_factor
160
+ if self.tokens > ceiling:
161
+ self.tokens = ceiling
162
+
163
+ def wait_for(
164
+ self, now: float | Fraction, cost: float | Fraction = 1.0, floor: float | Fraction = 0.0
165
+ ) -> int:
166
+ """Сообщает, сколько ждать до появления нужного запаса.
167
+
168
+ Порог ``floor`` - это доля ёмкости, которую запрос обязан оставить
169
+ после себя. Он и есть правило допуска по классу: чем менее защищён
170
+ класс, тем больше он обязан оставить, и тем раньше он уступает.
171
+
172
+ Пауза округляется вверх и строго больше точной величины: к целой части
173
+ прибавляется WAIT_GUARD_MS. Вровень привело бы повторную попытку ровно
174
+ на границу, где запаса ещё нет из-за последнего бита деления, - и вызов
175
+ отказал бы, прождав всё положенное. Величина объявлена спецификацией, а
176
+ не выбрана здесь: трасса меток отправки сравнивается между реализациями,
177
+ и округление обязано совпадать.
178
+
179
+ Args:
180
+ now (float): Текущий момент, монотонные секунды.
181
+ cost (float): Сколько нужно занять.
182
+ floor (float): Доля ёмкости, которая обязана остаться после займа.
183
+
184
+ Returns:
185
+ int: Миллисекунды ожидания. Ноль, если занять можно прямо сейчас.
186
+ """
187
+ charge, share = _exact(cost), _exact(floor)
188
+ if charge < 0 or not 0 <= share <= 1:
189
+ raise ValueError("стоимость не может быть отрицательной, доля должна быть в [0, 1]")
190
+ self._refill(now)
191
+ needed = charge + self.limits.capacity * _exact(self.factor) * share
192
+
193
+ # Ждать приходится дольшего из двух пределов: запрос проходит, только
194
+ # когда хватает и запаса, и права на залп.
195
+ by_tokens = 0
196
+ if self.tokens < needed:
197
+ if self.limits.refill_per_second <= 0:
198
+ return MAX_WAIT_MS
199
+ by_tokens = (
200
+ int(((needed - _exact(self.tokens)) / self._refill_rate) * 1000) + WAIT_GUARD_MS
201
+ )
202
+
203
+ by_burst = 0
204
+ if self.allowance < charge:
205
+ by_burst = (
206
+ int(((charge - _exact(self.allowance)) / self._burst_rate) * 1000) + WAIT_GUARD_MS
207
+ )
208
+
209
+ return max(by_tokens, by_burst)
210
+
211
+ def take(self, now: float | Fraction, cost: float | Fraction = 1.0) -> None:
212
+ """Занимает запас без проверки.
213
+
214
+ Проверять обязан вызывающий: разделение нужно затем, что при вложенных
215
+ вёдрах занять надо либо во всех сразу, либо ни в одном.
216
+
217
+ Args:
218
+ now (float): Текущий момент, монотонные секунды.
219
+ cost (float): Сколько занять.
220
+
221
+ Returns:
222
+ None
223
+ """
224
+ charge = _exact(cost)
225
+ if charge < 0:
226
+ raise ValueError("стоимость не может быть отрицательной")
227
+ self._refill(now)
228
+ self.tokens = _exact(self.tokens) - charge
229
+ self.allowance = _exact(self.allowance) - charge
230
+
231
+
232
+ class Budget:
233
+ """Вложенные вёдра бюджета для одной сетевой идентичности.
234
+
235
+ Args:
236
+ names (tuple[str, ...]): Имена вёдер в порядке расхода. Порядок
237
+ нормативен: сначала общее, потом ведро аккаунта.
238
+ """
239
+
240
+ __slots__ = (
241
+ "_buckets",
242
+ "_demanded_at",
243
+ "_suspended_until",
244
+ "_lock",
245
+ "_accounts",
246
+ "_monitoring",
247
+ )
248
+
249
+ def __init__(self, names: tuple[str, ...] = ("host", "account", "write")) -> None:
250
+ self._accounts: dict[str, Budget] = {}
251
+ self._monitoring: dict[object, tuple[tuple[TokenBucket, ...], tuple[MarketWatch, ...]]] = {}
252
+ self._lock = RLock()
253
+ self._buckets = tuple(TokenBucket(BUCKETS[name]) for name in names)
254
+ #: Когда каждый класс последний раз просил бюджет.
255
+ #:
256
+ #: Порог складывается только из долей претендующих. Без этого доля
257
+ #: превратилась бы из пола в потолок: цикл обновлений на пустой
258
+ #: площадке уступал бы тем, кто не пришёл.
259
+ self._demanded_at: dict[RequestClass, Fraction] = {}
260
+
261
+ #: До какого момента класс снят с очереди.
262
+ #:
263
+ #: Вторая ступень реакции на ограничение частоты. Снятие держится до
264
+ #: конца остывания идентичности.
265
+ self._suspended_until: dict[RequestClass, Fraction] = {}
266
+
267
+ def for_account(self, account: str) -> Budget:
268
+ """Возвращает личное ведро под общим сетевым пределом и общей блокировкой."""
269
+ if not isinstance(account, str) or not account.strip():
270
+ raise ValueError("ключ аккаунта не может быть пустым")
271
+ with self._lock:
272
+ if account not in self._accounts:
273
+ child = Budget(names=())
274
+ child._lock = self._lock
275
+ child._demanded_at = self._demanded_at
276
+ child._suspended_until = self._suspended_until
277
+ child._monitoring = self._monitoring
278
+ buckets = []
279
+ for bucket in self._buckets:
280
+ if bucket.limits.name == "account":
281
+ own = TokenBucket(bucket.limits)
282
+ own.scale(bucket.factor)
283
+ buckets.append(own)
284
+ else:
285
+ buckets.append(bucket)
286
+ child._buckets = tuple(buckets)
287
+ self._accounts[account] = child
288
+ return self._accounts[account]
289
+
290
+ def monitoring_plan(self, watches: tuple[MarketWatch, ...], now: float) -> MonitoringPlan:
291
+ """Проверяет суммарный прогноз под общей блокировкой, не расходуя токены."""
292
+ validate_watches(watches)
293
+ with self._lock:
294
+ rate = sum((one.requests_per_second for one in watches), Fraction())
295
+ limits = []
296
+ for bucket in self._buckets:
297
+ if bucket.limits.unit != "requests":
298
+ continue
299
+ used = sum(
300
+ (
301
+ one.requests_per_second
302
+ for buckets, existing in self._monitoring.values()
303
+ if any(bucket is other for other in buckets)
304
+ for one in existing
305
+ ),
306
+ Fraction(),
307
+ )
308
+ capacity = (
309
+ _exact(bucket.limits.refill_per_second)
310
+ * _exact(bucket.factor)
311
+ * _exact(FLOOR_SHARE[RequestClass.MONITORING])
312
+ )
313
+ limits.append(MonitoringLimit(bucket.limits.name, used + rate, capacity))
314
+ occupied = {
315
+ one.watch_id for _, existing in self._monitoring.values() for one in existing
316
+ }
317
+ reason = (
318
+ "watch_id_in_use"
319
+ if any(one.watch_id in occupied for one in watches)
320
+ else "monitoring_suspended"
321
+ if self.is_suspended(RequestClass.MONITORING, now)
322
+ else "forecast_exceeds_budget"
323
+ if any(one.requests_per_second > one.available_per_second for one in limits)
324
+ else None
325
+ )
326
+ return MonitoringPlan(
327
+ reason is None,
328
+ rate,
329
+ tuple(limits),
330
+ tuple(one.watch_id for one in watches) if reason else (),
331
+ reason,
332
+ )
333
+
334
+ @contextmanager
335
+ def admit_monitoring(
336
+ self, watches: tuple[MarketWatch, ...], now: float
337
+ ) -> Iterator[MonitoringPlan]:
338
+ """Регистрирует весь набор атомарно и освобождает его при любом выходе."""
339
+ token = object()
340
+ with self._lock:
341
+ plan = self.monitoring_plan(watches, now)
342
+ if not plan.admitted:
343
+ raise BudgetExhaustedError(
344
+ f"наблюдения не допущены ({plan.reason}); снимите или измените: "
345
+ + ", ".join(plan.rejected_watch_ids)
346
+ )
347
+ self._monitoring[token] = (self._buckets, watches)
348
+ try:
349
+ yield plan
350
+ finally:
351
+ with self._lock:
352
+ del self._monitoring[token]
353
+
354
+ def suspend(self, classes: tuple[RequestClass, ...], *, until: float | Fraction) -> None:
355
+ """Снимает классы запросов с очереди до названного момента.
356
+
357
+ Вторая ступень реакции на ограничение частоты. Площадка сказала
358
+ «слишком быстро» второй раз в окне, и урезания ёмкости оказалось мало:
359
+ снимаются те классы, без которых клиент остаётся клиентом - наблюдение
360
+ за рынком и автоматика.
361
+
362
+ Снятие держится до конца остывания идентичности и снимается вместе с
363
+ ним. Само по себе оно не истекает раньше: истекающее раньше означало бы,
364
+ что клиент вернулся к прежнему темпу, ничего не дождавшись.
365
+
366
+ Args:
367
+ classes (tuple[RequestClass, ...]): Какие классы снять.
368
+ until (float): До какого момента, монотонные секунды.
369
+
370
+ Returns:
371
+ None
372
+ """
373
+ deadline = _exact(until)
374
+ with self._lock:
375
+ for request_class in classes:
376
+ self._suspended_until[request_class] = max(
377
+ self._suspended_until.get(request_class, Fraction()), deadline
378
+ )
379
+
380
+ def is_suspended(self, request_class: RequestClass, now: float | Fraction) -> bool:
381
+ """Сообщает, снят ли класс с очереди сейчас.
382
+
383
+ Args:
384
+ request_class (RequestClass): Класс запроса.
385
+ now (float): Текущий момент, монотонные секунды.
386
+
387
+ Returns:
388
+ bool: True, если класс снят и запрос по нему сейчас не пройдёт.
389
+ """
390
+ with self._lock:
391
+ return _exact(now) < self._suspended_until.get(request_class, Fraction())
392
+
393
+ def _floor_for(self, request_class: RequestClass, now: float | Fraction) -> Fraction:
394
+ """Считает порог допуска для класса по нынешнему спросу.
395
+
396
+ Порог - сумма долей тех классов, которые защищены сильнее И вправду
397
+ претендуют на ёмкость. Претендующим считается класс, обращавшийся за
398
+ бюджетом в последние DEMAND_WINDOW_MS.
399
+
400
+ Условие про спрос принципиально. Доля - это пол, а не потолок: она
401
+ обещает, что менее защищённый не съест последнюю долю более
402
+ защищённого. Обещание имеет смысл, только когда более защищённому есть
403
+ что съесть. Вытеснять некого, когда никто не претендует, и запрещать
404
+ циклу обновлений брать больше четверти ведра на пустой площадке значило
405
+ бы наказывать его за чужое бездействие.
406
+
407
+ Args:
408
+ request_class (RequestClass): Класс, для которого считается порог.
409
+ now (float): Текущий момент, монотонные секунды.
410
+
411
+ Returns:
412
+ Fraction: Точная доля ёмкости, которая обязана остаться после займа.
413
+ """
414
+ deadline = _exact(now) - Fraction(DEMAND_WINDOW_MS, 1000)
415
+ floor = Fraction()
416
+ for other in RequestClass:
417
+ if other is request_class:
418
+ break
419
+ if other in self._demanded_at and self._demanded_at[other] >= deadline:
420
+ floor += _exact(FLOOR_SHARE[other])
421
+ return floor
422
+
423
+ def reserve(
424
+ self,
425
+ now: float,
426
+ cost: float = 1.0,
427
+ request_class: RequestClass = RequestClass.INTERACTIVE,
428
+ *,
429
+ action: bool = False,
430
+ ) -> Reservation:
431
+ """Пытается занять бюджет во всех вёдрах сразу.
432
+
433
+ Занимает либо во всех, либо ни в одном. Частичный расход означал бы, что
434
+ отказавший запрос всё равно потратил чужой запас, и при частых отказах
435
+ бюджет утекал бы в никуда.
436
+
437
+ Класс запроса задаёт порог: сколько ёмкости обязано остаться после
438
+ займа. Доля - это ПОЛ, а не потолок; она не ограничивает класс сверху, а
439
+ обещает, что менее защищённый не съест последнюю долю более защищённого.
440
+ Прежде класс объявлялся у каждой операции и до бюджета не доходил вовсе:
441
+ собственный мониторинг продавца вытеснял ответы покупателям на общих
442
+ основаниях - ровно то, ради чего доли и придуманы.
443
+
444
+ Умолчание - interactive. Не потому, что оно безобидно, а потому, что
445
+ оно самое защищённое: вызов, забывший объявить класс, не должен из-за
446
+ забывчивости уступить мониторингу.
447
+
448
+ Args:
449
+ now (float): Текущий момент, монотонные секунды.
450
+ cost (float): Стоимость запроса.
451
+ request_class (RequestClass): Класс запроса.
452
+ action (bool): Начало логической записи. Списывает одну единицу
453
+ write вместе с первым запросом; остальные запросы - без неё.
454
+
455
+ Returns:
456
+ Reservation: Выдан ли бюджет, и сколько ждать, если нет.
457
+ """
458
+ moment, charge = _exact(now), _exact(cost)
459
+ if charge < 0:
460
+ raise ValueError("стоимость не может быть отрицательной")
461
+ with self._lock:
462
+ self._demanded_at[request_class] = max(
463
+ moment, self._demanded_at.get(request_class, moment)
464
+ )
465
+
466
+ # Снятый класс не проходит вовсе, сколько бы ни было в ведре. Ждать он
467
+ # обязан до конца остывания, а не до появления токена.
468
+ #
469
+ # Округление то же, что и у ожидания запаса: пауза строго больше точной
470
+ # величины. Здесь константа прежде стояла литералом - то есть правило
471
+ # выполнялось по совпадению, и правка объявленного числа обошла бы это
472
+ # место стороной.
473
+ if self.is_suspended(request_class, moment):
474
+ return Reservation(
475
+ granted=False,
476
+ wait_ms=wait_until_ms(moment, self._suspended_until[request_class]),
477
+ bucket="suspended",
478
+ )
479
+
480
+ floor = self._floor_for(request_class, moment)
481
+ charges = tuple(
482
+ (bucket, Fraction(1) if bucket.limits.unit == "actions_per_hour" else charge)
483
+ for bucket in self._buckets
484
+ if bucket.limits.unit != "actions_per_hour" or action
485
+ )
486
+ for bucket, charge in charges:
487
+ wait = bucket.wait_for(moment, charge, floor)
488
+ if wait:
489
+ return Reservation(granted=False, wait_ms=wait, bucket=bucket.limits.name)
490
+
491
+ for bucket, charge in charges:
492
+ bucket.take(moment, charge)
493
+ return Reservation(granted=True, wait_ms=0, bucket="")
494
+
495
+ def scale(self, factor: float | Fraction) -> None:
496
+ """Урезает ёмкость всех вёдер до доли от объявленной.
497
+
498
+ Нужно реакции на ограничение частоты. Прежде она была объявлена
499
+ спецификацией и не выполнялась нигде: ответ 429 переводился в ошибку и
500
+ уходил в политику повторов, но ёмкость ведра при этом не менялась -
501
+ следующий залп был ровно таким же, каким был до ограничения. Это худший
502
+ из возможных ответов на ограничение.
503
+
504
+ Урезается ёмкость, а не запас. Запас восстановится сам по объявленной
505
+ скорости; ёмкость решает, сколько можно взять залпом, и именно она
506
+ отвечает на «слишком быстро».
507
+
508
+ Args:
509
+ factor (float): Доля от объявленной ёмкости, от нуля до единицы.
510
+
511
+ Returns:
512
+ None
513
+
514
+ Raises:
515
+ ValueError: Если доля вне разумных границ. Множитель больше единицы
516
+ означал бы, что ограничение частоты РАЗРЕШАЕТ ходить чаще.
517
+ """
518
+ with self._lock:
519
+ if not 0 < factor <= 1:
520
+ raise ValueError(
521
+ f"доля ёмкости {factor} вне границ (0, 1]: множитель больше "
522
+ "единицы означал бы, что ограничение частоты разрешает ходить чаще"
523
+ )
524
+ for bucket in self._buckets:
525
+ bucket.scale(factor)
526
+ for account in self._accounts.values():
527
+ account.scale(factor)
528
+
529
+ def require(
530
+ self,
531
+ now: float,
532
+ cost: float = 1.0,
533
+ request_class: RequestClass = RequestClass.INTERACTIVE,
534
+ *,
535
+ action: bool = False,
536
+ ) -> Reservation:
537
+ """Занимает бюджет или отказывает, если ждать пришлось бы слишком долго.
538
+
539
+ Args:
540
+ now (float): Текущий момент, монотонные секунды.
541
+ cost (float): Стоимость запроса.
542
+
543
+ Returns:
544
+ Reservation: Всегда выданный либо с ожиданием не дольше предела.
545
+
546
+ Raises:
547
+ BudgetExhaustedError: Если ожидание превысило бы предел. Запрос при
548
+ этом не отправляется вовсе - в этом весь смысл: ошибка означает
549
+ решение SDK не ходить, а не ответ площадки.
550
+ """
551
+ reservation = self.reserve(now, cost, request_class, action=action)
552
+ if reservation.granted:
553
+ return reservation
554
+
555
+ # Отменяемому классу отказывают сразу, не дожидаясь предела ожидания.
556
+ # Ждать наблюдению бессмысленно: к моменту пополнения оно устареет, а
557
+ # место в очереди займёт прямо сейчас. Прочим отказать нельзя - их
558
+ # никто не повторит за пользователя.
559
+ if ON_REFUSAL[request_class] == "refuse":
560
+ raise BudgetExhaustedError(
561
+ f"бюджет исчерпан для класса {request_class}: ведро "
562
+ f"{reservation.bucket} освободится через {reservation.wait_ms} мс. "
563
+ "Класс объявлен отменяемым и уступает всем прочим. Запрос не отправлен"
564
+ )
565
+
566
+ if reservation.wait_ms <= MAX_WAIT_MS:
567
+ return reservation
568
+ raise BudgetExhaustedError(
569
+ f"бюджет исчерпан: ведро {reservation.bucket} освободится через "
570
+ f"{reservation.wait_ms} мс, предел ожидания {MAX_WAIT_MS} мс. "
571
+ "Запрос не отправлен"
572
+ )
573
+
574
+
575
+ #: Стоимость одного отправленного запроса.
576
+ #:
577
+ #: Расходуются именно отправленные запросы, включая повторы и переходы по
578
+ #: редиректам, а не логические операции.
579
+ REQUEST_COST: Final[float] = 1.0