rabbitkit 0.9.0__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 (95) hide show
  1. rabbitkit/__init__.py +201 -0
  2. rabbitkit/_version.py +3 -0
  3. rabbitkit/aio/__init__.py +31 -0
  4. rabbitkit/async_/__init__.py +9 -0
  5. rabbitkit/async_/batch.py +213 -0
  6. rabbitkit/async_/broker.py +1123 -0
  7. rabbitkit/async_/connection.py +274 -0
  8. rabbitkit/async_/pool.py +363 -0
  9. rabbitkit/async_/transport.py +877 -0
  10. rabbitkit/asyncapi/__init__.py +5 -0
  11. rabbitkit/asyncapi/generator.py +219 -0
  12. rabbitkit/asyncapi/schema.py +98 -0
  13. rabbitkit/cli/__init__.py +77 -0
  14. rabbitkit/cli/_utils.py +38 -0
  15. rabbitkit/cli/commands/__init__.py +0 -0
  16. rabbitkit/cli/commands/dlq.py +190 -0
  17. rabbitkit/cli/commands/health.py +34 -0
  18. rabbitkit/cli/commands/migrate.py +570 -0
  19. rabbitkit/cli/commands/routes.py +88 -0
  20. rabbitkit/cli/commands/run.py +144 -0
  21. rabbitkit/cli/commands/shell.py +72 -0
  22. rabbitkit/cli/commands/topology.py +346 -0
  23. rabbitkit/concurrency.py +451 -0
  24. rabbitkit/core/__init__.py +5 -0
  25. rabbitkit/core/app.py +323 -0
  26. rabbitkit/core/config.py +849 -0
  27. rabbitkit/core/env_config.py +251 -0
  28. rabbitkit/core/errors.py +199 -0
  29. rabbitkit/core/logging.py +261 -0
  30. rabbitkit/core/message.py +235 -0
  31. rabbitkit/core/path.py +53 -0
  32. rabbitkit/core/pipeline.py +1289 -0
  33. rabbitkit/core/protocols.py +349 -0
  34. rabbitkit/core/registry.py +284 -0
  35. rabbitkit/core/route.py +329 -0
  36. rabbitkit/core/router.py +142 -0
  37. rabbitkit/core/topology.py +261 -0
  38. rabbitkit/core/topology_dispatch.py +74 -0
  39. rabbitkit/core/types.py +324 -0
  40. rabbitkit/dashboard/__init__.py +5 -0
  41. rabbitkit/dashboard/app.py +212 -0
  42. rabbitkit/di/__init__.py +19 -0
  43. rabbitkit/di/context.py +193 -0
  44. rabbitkit/di/depends.py +42 -0
  45. rabbitkit/di/resolver.py +503 -0
  46. rabbitkit/dlq.py +320 -0
  47. rabbitkit/experimental/__init__.py +50 -0
  48. rabbitkit/fastapi.py +91 -0
  49. rabbitkit/health.py +654 -0
  50. rabbitkit/highload/__init__.py +10 -0
  51. rabbitkit/highload/backpressure.py +514 -0
  52. rabbitkit/highload/batch.py +448 -0
  53. rabbitkit/locking.py +277 -0
  54. rabbitkit/management.py +470 -0
  55. rabbitkit/middleware/__init__.py +27 -0
  56. rabbitkit/middleware/base.py +125 -0
  57. rabbitkit/middleware/circuit_breaker.py +131 -0
  58. rabbitkit/middleware/compression.py +267 -0
  59. rabbitkit/middleware/deduplication.py +651 -0
  60. rabbitkit/middleware/error_classifier.py +43 -0
  61. rabbitkit/middleware/exception.py +105 -0
  62. rabbitkit/middleware/metrics.py +440 -0
  63. rabbitkit/middleware/otel.py +203 -0
  64. rabbitkit/middleware/rate_limit.py +247 -0
  65. rabbitkit/middleware/retry.py +540 -0
  66. rabbitkit/middleware/signing.py +682 -0
  67. rabbitkit/middleware/timeout.py +291 -0
  68. rabbitkit/py.typed +0 -0
  69. rabbitkit/queue_metrics.py +174 -0
  70. rabbitkit/results/__init__.py +6 -0
  71. rabbitkit/results/backend.py +102 -0
  72. rabbitkit/results/middleware.py +123 -0
  73. rabbitkit/rpc.py +632 -0
  74. rabbitkit/serialization/__init__.py +25 -0
  75. rabbitkit/serialization/base.py +35 -0
  76. rabbitkit/serialization/json.py +122 -0
  77. rabbitkit/serialization/msgspec.py +136 -0
  78. rabbitkit/serialization/pipeline.py +255 -0
  79. rabbitkit/streams.py +139 -0
  80. rabbitkit/sync/__init__.py +11 -0
  81. rabbitkit/sync/batch.py +595 -0
  82. rabbitkit/sync/broker.py +996 -0
  83. rabbitkit/sync/connection.py +209 -0
  84. rabbitkit/sync/pool.py +262 -0
  85. rabbitkit/sync/transport.py +1085 -0
  86. rabbitkit/testing/__init__.py +20 -0
  87. rabbitkit/testing/app.py +99 -0
  88. rabbitkit/testing/broker.py +540 -0
  89. rabbitkit/testing/fixtures.py +56 -0
  90. rabbitkit-0.9.0.dist-info/METADATA +575 -0
  91. rabbitkit-0.9.0.dist-info/RECORD +95 -0
  92. rabbitkit-0.9.0.dist-info/WHEEL +5 -0
  93. rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
  94. rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
  95. rabbitkit-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,514 @@
1
+ """Publish-side flow control — backpressure, rate limiting, in-flight tracking.
2
+
3
+ ``FlowController`` is NOT a middleware — it is used by transports/brokers
4
+ to throttle outgoing publishes when the system is under pressure.
5
+
6
+ Three pressure signals:
7
+ 1. **connection.blocked** — RabbitMQ signals memory/disk alarm
8
+ 2. **in-flight limit** — max concurrent unconfirmed publishes
9
+ 3. **rate limit** — token-bucket limiter (messages per second)
10
+
11
+ Configurable behaviour when blocked:
12
+ - ``"wait"`` — block until unblocked / slot available / token available
13
+ - ``"raise"`` — raise ``BackpressureError`` immediately
14
+ - ``"drop"`` — return ``False`` (caller should discard the message)
15
+
16
+ The ``on_blocked`` string is translated ONCE in ``FlowController.__init__``
17
+ into a ``_BlockedPolicy`` strategy, eliminating the stringly-typed
18
+ ``if self._config.on_blocked == ...`` dispatch that was sprinkled through
19
+ ``acquire`` / ``acquire_async``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import logging
26
+ import threading
27
+ import time
28
+ from typing import Protocol
29
+
30
+ from rabbitkit.core.config import BackpressureConfig
31
+ from rabbitkit.core.errors import BackpressureError
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ # ── Token bucket rate limiter ────────────────────────────────────────────
37
+
38
+
39
+ class _TokenBucket:
40
+ """Simple token-bucket rate limiter (sync, threading.Lock).
41
+
42
+ Refills ``rate`` tokens per second. ``acquire()`` consumes one token.
43
+ Thread-safe via ``threading.Lock``. Use in sync contexts only.
44
+ """
45
+
46
+ def __init__(self, rate: int, poll_interval: float = 0.01) -> None:
47
+ self._rate = rate
48
+ self._poll_interval = poll_interval
49
+ self._tokens = float(rate)
50
+ self._max_tokens = float(rate)
51
+ self._last_refill = time.monotonic()
52
+ self._lock = threading.Lock()
53
+
54
+ def _refill(self) -> None:
55
+ now = time.monotonic()
56
+ elapsed = now - self._last_refill
57
+ self._tokens = min(self._max_tokens, self._tokens + elapsed * self._rate)
58
+ self._last_refill = now
59
+
60
+ def acquire(self) -> bool:
61
+ """Try to consume one token. Returns True if available."""
62
+ with self._lock:
63
+ self._refill()
64
+ if self._tokens >= 1.0:
65
+ self._tokens -= 1.0
66
+ return True
67
+ return False
68
+
69
+ def wait(self, timeout: float | None = None) -> bool:
70
+ """Block until a token is available or timeout expires.
71
+
72
+ Returns True if token acquired, False on timeout.
73
+ """
74
+ deadline = None if timeout is None else time.monotonic() + timeout
75
+ while True:
76
+ if self.acquire():
77
+ return True
78
+ if deadline is not None and time.monotonic() >= deadline:
79
+ return False
80
+ # Sleep a short interval before retry
81
+ time.sleep(self._poll_interval)
82
+
83
+
84
+ class _AsyncTokenBucket:
85
+ """Async-native token-bucket rate limiter.
86
+
87
+ Same algorithm as ``_TokenBucket`` but uses ``asyncio.Lock`` so it never
88
+ blocks the event loop. Instantiated lazily inside the event loop.
89
+ """
90
+
91
+ def __init__(self, rate: int) -> None:
92
+ self._rate = rate
93
+ self._tokens = float(rate)
94
+ self._max_tokens = float(rate)
95
+ self._last_refill = time.monotonic()
96
+ self._lock: asyncio.Lock | None = None # created lazily inside loop
97
+
98
+ def _ensure_lock(self) -> asyncio.Lock:
99
+ if self._lock is None:
100
+ self._lock = asyncio.Lock()
101
+ return self._lock
102
+
103
+ def _refill(self) -> None:
104
+ now = time.monotonic()
105
+ elapsed = now - self._last_refill
106
+ self._tokens = min(self._max_tokens, self._tokens + elapsed * self._rate)
107
+ self._last_refill = now
108
+
109
+ async def acquire(self) -> bool:
110
+ """Try to consume one token (non-blocking). Returns True if available."""
111
+ async with self._ensure_lock():
112
+ self._refill()
113
+ if self._tokens >= 1.0:
114
+ self._tokens -= 1.0
115
+ return True
116
+ return False
117
+
118
+ async def wait(self, timeout: float | None = None) -> bool:
119
+ """Async-wait until a token is available or timeout expires.
120
+
121
+ Calculates the exact time until the next token refills instead of
122
+ polling every 10 ms, reducing unnecessary wakeups at high rates.
123
+ """
124
+ deadline = None if timeout is None else time.monotonic() + timeout
125
+ while True:
126
+ async with self._ensure_lock():
127
+ self._refill()
128
+ if self._tokens >= 1.0:
129
+ self._tokens -= 1.0
130
+ return True
131
+ # Calculate how long until one token is available
132
+ tokens_needed = 1.0 - self._tokens
133
+ sleep_for = tokens_needed / self._rate
134
+ if deadline is not None:
135
+ remaining = deadline - time.monotonic()
136
+ if remaining <= 0:
137
+ return False
138
+ sleep_for = min(sleep_for, remaining)
139
+ await asyncio.sleep(sleep_for)
140
+
141
+
142
+ # ── Blocked-policy strategies ────────────────────────────────────────────
143
+
144
+
145
+ # ``reason`` values passed to the policy strategies. These are *semantic*
146
+ # inputs (not stringly-typed dispatch): each names the pressure signal that
147
+ # fired so the wait policy knows which primitive to block on.
148
+ _REASON_BLOCKED = "blocked" # connection.blocked notification
149
+ _REASON_RATE = "rate" # rate-limiter token unavailable
150
+ _REASON_SLOT = "slot" # in-flight limit reached
151
+
152
+
153
+ class _BlockedPolicy(Protocol):
154
+ """Strategy encapsulating the ``on_blocked`` behaviour.
155
+
156
+ ``handle`` / ``handle_async`` perform the full action (raise, drop, or
157
+ block) and are called OUTSIDE the transport lock so the lock discipline
158
+ (C-5) is preserved. Returns ``True`` when the wait policy succeeds and
159
+ the caller may proceed; ``False`` when the message should be dropped
160
+ (drop policy, or wait-policy timeout); raises ``BackpressureError`` for
161
+ the raise policy.
162
+ """
163
+
164
+ def handle(self, fc: FlowController, reason: str, timeout: float | None) -> bool: ...
165
+
166
+ async def handle_async(self, fc: FlowController, reason: str, timeout: float | None) -> bool: ...
167
+
168
+
169
+ class _WaitPolicy:
170
+ """Block until the pressure clears. Returns False on timeout."""
171
+
172
+ def handle(self, fc: FlowController, reason: str, timeout: float | None) -> bool:
173
+ if reason == _REASON_BLOCKED:
174
+ return fc._unblock_event.wait(timeout=timeout)
175
+ if reason == _REASON_RATE:
176
+ rl = fc._rate_limiter
177
+ return rl is not None and rl.wait(timeout=timeout)
178
+ # _REASON_SLOT — the only remaining reason (reasons are internal and
179
+ # exhaustive; a former _REASON_RATE_RETRY "give up" reason was removed
180
+ # because dropping a waiter with deadline remaining violated the
181
+ # on_blocked="wait" contract).
182
+ return fc._slot_event.wait(timeout=timeout)
183
+
184
+ async def handle_async(self, fc: FlowController, reason: str, timeout: float | None) -> bool:
185
+ if reason == _REASON_BLOCKED:
186
+ assert fc._async_unblock_event is not None
187
+ try:
188
+ async with asyncio.timeout(timeout):
189
+ await fc._async_unblock_event.wait()
190
+ return True
191
+ except TimeoutError:
192
+ return False
193
+ if reason == _REASON_RATE:
194
+ rl = fc._async_rate_limiter
195
+ if rl is None:
196
+ return False
197
+ return await rl.wait(timeout=timeout)
198
+ # _REASON_SLOT — see the sync counterpart above.
199
+ assert fc._async_slot_event is not None
200
+ try:
201
+ async with asyncio.timeout(timeout):
202
+ await fc._async_slot_event.wait()
203
+ return True
204
+ except TimeoutError:
205
+ return False
206
+
207
+
208
+ class _RaisePolicy:
209
+ """Raise ``BackpressureError`` immediately for any pressure signal."""
210
+
211
+ def handle(self, fc: FlowController, reason: str, timeout: float | None) -> bool:
212
+ raise BackpressureError(_raise_message(reason, fc))
213
+
214
+ async def handle_async(self, fc: FlowController, reason: str, timeout: float | None) -> bool:
215
+ raise BackpressureError(_raise_message(reason, fc))
216
+
217
+
218
+ class _DropPolicy:
219
+ """Return False immediately — the caller should discard the message."""
220
+
221
+ def handle(self, fc: FlowController, reason: str, timeout: float | None) -> bool:
222
+ return False
223
+
224
+ async def handle_async(self, fc: FlowController, reason: str, timeout: float | None) -> bool:
225
+ return False
226
+
227
+
228
+ def _raise_message(reason: str, fc: FlowController) -> str:
229
+ if reason == _REASON_BLOCKED:
230
+ return "Connection is blocked by RabbitMQ"
231
+ if reason == _REASON_SLOT:
232
+ return f"In-flight limit reached ({fc._config.max_in_flight})"
233
+ # _REASON_RATE
234
+ return "Rate limit exceeded"
235
+
236
+
237
+ # ── FlowController ──────────────────────────────────────────────────────
238
+
239
+
240
+ class FlowController:
241
+ """Publish-side flow control.
242
+
243
+ Usage::
244
+
245
+ fc = FlowController(BackpressureConfig(max_in_flight=500, rate_limit=1000))
246
+
247
+ # Before publish:
248
+ if fc.acquire(timeout=5.0):
249
+ transport.publish(envelope)
250
+ fc.release()
251
+ else:
252
+ # message dropped or error raised (depends on on_blocked)
253
+ ...
254
+
255
+ # Register with transport callbacks:
256
+ transport.on_blocked(fc.on_blocked)
257
+ transport.on_unblocked(fc.on_unblocked)
258
+ """
259
+
260
+ def __init__(self, config: BackpressureConfig | None = None) -> None:
261
+ self._config = config or BackpressureConfig()
262
+ self._blocked = False
263
+ self._in_flight = 0
264
+ self._lock = threading.Lock()
265
+ self._unblock_event = threading.Event()
266
+ self._unblock_event.set() # start unblocked
267
+ self._slot_event = threading.Event()
268
+ self._slot_event.set() # start with slots available
269
+
270
+ # Async equivalents
271
+ self._async_lock: asyncio.Lock | None = None # lazily created
272
+ self._async_unblock_event: asyncio.Event | None = None # lazily created
273
+ self._async_slot_event: asyncio.Event | None = None # lazily created
274
+
275
+ # Rate limiter (optional)
276
+ # Sync path uses threading.Lock-based bucket; async path uses asyncio.Lock
277
+ # so acquiring tokens never blocks the event loop.
278
+ self._rate_limiter: _TokenBucket | None = None
279
+ self._async_rate_limiter: _AsyncTokenBucket | None = None
280
+ if self._config.rate_limit is not None:
281
+ poll = self._config.poll_interval_ms / 1000.0
282
+ self._rate_limiter = _TokenBucket(self._config.rate_limit, poll_interval=poll)
283
+ self._async_rate_limiter = _AsyncTokenBucket(self._config.rate_limit)
284
+
285
+ # Select the on_blocked strategy ONCE (public API stays a string for
286
+ # backward compat — the Strategy selection happens here).
287
+ _strategies: dict[str, type[_BlockedPolicy]] = {
288
+ "wait": _WaitPolicy,
289
+ "raise": _RaisePolicy,
290
+ "drop": _DropPolicy,
291
+ }
292
+ self._policy = _strategies[self._config.on_blocked]()
293
+
294
+ def _ensure_async_primitives(self) -> None:
295
+ """Lazily create asyncio primitives (must be called in event loop)."""
296
+ if self._async_lock is None:
297
+ self._async_lock = asyncio.Lock()
298
+ if self._async_unblock_event is None:
299
+ self._async_unblock_event = asyncio.Event()
300
+ if not self._blocked:
301
+ self._async_unblock_event.set()
302
+ if self._async_slot_event is None:
303
+ self._async_slot_event = asyncio.Event()
304
+ self._async_slot_event.set()
305
+
306
+ # ── Connection blocked/unblocked callbacks ───────────────────────────
307
+
308
+ def on_blocked(self) -> None:
309
+ """Called when RabbitMQ signals connection.blocked."""
310
+ self._blocked = True
311
+ self._unblock_event.clear()
312
+ if self._async_unblock_event is not None:
313
+ self._async_unblock_event.clear()
314
+ logger.warning("Connection blocked — backpressure active")
315
+
316
+ def on_unblocked(self) -> None:
317
+ """Called when RabbitMQ signals connection.unblocked."""
318
+ self._blocked = False
319
+ self._unblock_event.set()
320
+ if self._async_unblock_event is not None:
321
+ self._async_unblock_event.set()
322
+ logger.info("Connection unblocked — backpressure released")
323
+
324
+ # ── Properties ───────────────────────────────────────────────────────
325
+
326
+ @property
327
+ def is_blocked(self) -> bool:
328
+ """True if connection is currently blocked by RabbitMQ."""
329
+ return self._blocked
330
+
331
+ @property
332
+ def in_flight(self) -> int:
333
+ """Current number of in-flight (unconfirmed) publishes."""
334
+ return self._in_flight
335
+
336
+ # ── Sync acquire / release ───────────────────────────────────────────
337
+
338
+ def acquire(self, timeout: float | None = None) -> bool:
339
+ """Acquire a publish slot.
340
+
341
+ Checks (in order): not blocked, in-flight < max, rate OK.
342
+
343
+ Returns True if slot acquired, False if dropped.
344
+ Raises BackpressureError if ``on_blocked == "raise"`` and blocked.
345
+
346
+ With ``on_blocked="wait"`` a race loss (the slot we waited for was
347
+ taken by another contender) re-loops instead of silently dropping —
348
+ mirroring the async path (I-9). The loop is bounded by the deadline
349
+ derived from *timeout* / ``blocked_timeout``.
350
+ """
351
+ effective_timeout = timeout if timeout is not None else self._config.blocked_timeout
352
+
353
+ # 1. Check blocked state
354
+ if self._blocked:
355
+ if not self._policy.handle(self, _REASON_BLOCKED, effective_timeout):
356
+ return False
357
+
358
+ # 2. Check in-flight limit + rate limiter. The rate-limiter wait (which
359
+ # sleeps) must happen OUTSIDE self._lock (C-5): under the lock we only do
360
+ # non-blocking checks, then release the lock before any blocking wait.
361
+ # On a race loss (the slot/token we waited for was taken by another
362
+ # contender) we re-loop instead of dropping (I-9), bounded by deadline.
363
+ deadline = None if effective_timeout is None else time.monotonic() + effective_timeout
364
+
365
+ def _remaining() -> float | None:
366
+ return None if deadline is None else max(0.0, deadline - time.monotonic())
367
+
368
+ while True:
369
+ rate_needed = False
370
+ at_limit = False
371
+ with self._lock:
372
+ if self._in_flight < self._config.max_in_flight:
373
+ if self._rate_limiter is not None and not self._rate_limiter.acquire():
374
+ # No token right now; the (possibly blocking) wait happens
375
+ # outside the lock below. Fall through to the rate policy.
376
+ rate_needed = True
377
+ else:
378
+ self._in_flight += 1
379
+ if self._in_flight >= self._config.max_in_flight:
380
+ self._slot_event.clear()
381
+ return True
382
+ else:
383
+ at_limit = True
384
+ self._slot_event.clear()
385
+
386
+ if rate_needed:
387
+ # Policy dispatch outside the lock (C-5): wait/raise/drop.
388
+ if not self._policy.handle(self, _REASON_RATE, _remaining()):
389
+ return False
390
+ # wait() consumed a token atomically; now claim the in-flight slot.
391
+ with self._lock:
392
+ if self._in_flight < self._config.max_in_flight:
393
+ self._in_flight += 1
394
+ if self._in_flight >= self._config.max_in_flight:
395
+ self._slot_event.clear()
396
+ return True
397
+ # The slot was taken while we waited for a token; wait for a slot
398
+ # then re-loop and re-claim under the lock (I-9: was a silent drop).
399
+ if not self._slot_event.wait(timeout=_remaining()):
400
+ return False
401
+ continue
402
+
403
+ if at_limit:
404
+ # Policy dispatch outside the lock (C-5): wait/raise/drop.
405
+ if not self._policy.handle(self, _REASON_SLOT, _remaining()):
406
+ return False
407
+ # Re-loop and re-claim under the lock. If we lose the race again
408
+ # (still at limit), the loop clears the event and re-waits — this
409
+ # is the I-9 fix (previously we returned False on a single loss).
410
+ continue
411
+
412
+ def release(self) -> None:
413
+ """Release a publish slot after confirm/nack/timeout."""
414
+ with self._lock:
415
+ if self._in_flight > 0:
416
+ self._in_flight -= 1
417
+ self._slot_event.set()
418
+
419
+ # ── Async acquire / release ──────────────────────────────────────────
420
+
421
+ async def acquire_async(self, timeout: float | None = None) -> bool:
422
+ """Async variant of ``acquire``."""
423
+ self._ensure_async_primitives()
424
+ assert self._async_lock is not None
425
+ assert self._async_unblock_event is not None
426
+ effective_timeout = timeout if timeout is not None else self._config.blocked_timeout
427
+
428
+ # 1. Check blocked state
429
+ if self._blocked:
430
+ if not await self._policy.handle_async(self, _REASON_BLOCKED, effective_timeout):
431
+ return False
432
+
433
+ # 2. Check in-flight limit + rate. Rate-token waits and in-flight slot
434
+ # waits happen OUTSIDE the lock; on a race loss we re-wait (loop) instead
435
+ # of dropping (H-P4). With on_blocked="wait" a missing rate token now
436
+ # actually waits, mirroring the sync semantics.
437
+ deadline = None if effective_timeout is None else time.monotonic() + effective_timeout
438
+
439
+ def _remaining() -> float | None:
440
+ return None if deadline is None else max(0.0, deadline - time.monotonic())
441
+
442
+ while True:
443
+ rate_needed = False
444
+ at_limit = False
445
+ async with self._async_lock:
446
+ if self._in_flight < self._config.max_in_flight:
447
+ if self._async_rate_limiter is not None and not await self._async_rate_limiter.acquire():
448
+ # No token right now; the (possibly blocking) wait happens
449
+ # outside the lock below. Fall through to the rate policy.
450
+ rate_needed = True
451
+ else:
452
+ self._in_flight += 1
453
+ if self._in_flight >= self._config.max_in_flight:
454
+ assert self._async_slot_event is not None
455
+ self._async_slot_event.clear()
456
+ return True
457
+ else:
458
+ at_limit = True
459
+ assert self._async_slot_event is not None
460
+ self._async_slot_event.clear()
461
+
462
+ if rate_needed:
463
+ # Policy dispatch outside the lock (C-5): wait/raise/drop.
464
+ if not await self._policy.handle_async(self, _REASON_RATE, _remaining()):
465
+ return False
466
+ # Token consumed by wait(); claim the slot without another acquire.
467
+ async with self._async_lock:
468
+ if self._in_flight < self._config.max_in_flight:
469
+ self._in_flight += 1
470
+ if self._in_flight >= self._config.max_in_flight:
471
+ assert self._async_slot_event is not None
472
+ self._async_slot_event.clear()
473
+ return True
474
+ # The slot was taken while we waited for a token; wait for a slot
475
+ # then RE-LOOP and re-claim under the lock, exactly like the sync
476
+ # path (I-9/perf-M-1). The re-loop re-checks the rate bucket
477
+ # non-blockingly and, if it's empty again (another contender took
478
+ # the refill), re-enters the bounded _REASON_RATE wait — it must
479
+ # NOT drop. A previous version demanded a second token HERE, under
480
+ # the lock, and dropped (_REASON_RATE_RETRY) when the bucket was
481
+ # momentarily empty — silently failing a waiter with almost its
482
+ # whole deadline remaining, the exact single-race-loss drop this
483
+ # loop exists to prevent (caught by the contender stress test
484
+ # failing ~1% of runs under CPU load, in ~70ms of a 10s budget).
485
+ assert self._async_slot_event is not None
486
+ _rem = _remaining()
487
+ if _rem is not None and _rem <= 0:
488
+ return False
489
+ try:
490
+ async with asyncio.timeout(_rem):
491
+ await self._async_slot_event.wait()
492
+ except TimeoutError:
493
+ return False
494
+ continue
495
+
496
+ if at_limit:
497
+ # Policy dispatch outside the lock (C-5): wait/raise/drop.
498
+ assert self._async_slot_event is not None
499
+ if not await self._policy.handle_async(self, _REASON_SLOT, _remaining()):
500
+ return False
501
+ # Re-loop and re-claim under the lock. If we lose the race again
502
+ # (still at limit), the loop clears the event and re-waits - this
503
+ # is the H-P4 fix (previously we returned False on a single loss).
504
+ continue
505
+
506
+ async def release_async(self) -> None:
507
+ """Async variant of ``release``."""
508
+ self._ensure_async_primitives()
509
+ assert self._async_lock is not None
510
+ assert self._async_slot_event is not None
511
+ async with self._async_lock:
512
+ if self._in_flight > 0:
513
+ self._in_flight -= 1
514
+ self._async_slot_event.set()