ringo-task-queue 0.1.0.dev2__py3-none-win_amd64.whl → 0.1.0.dev4__py3-none-win_amd64.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.
@@ -17,6 +17,7 @@ import grpc
17
17
 
18
18
  from . import _pb
19
19
  from ._proto.ringo.v1 import queue_pb2
20
+ from .concurrency import AdaptiveConcurrency, ResourceConcurrency, _Member
20
21
  from .errors import (
21
22
  IncompatibleVersionError,
22
23
  LeaseLostError,
@@ -145,6 +146,10 @@ class _Assignment:
145
146
  renew_task: asyncio.Task[Any] | None = None
146
147
  alive: bool = True
147
148
  completion_started: bool = False
149
+ completion_success: bool = False
150
+ completion_congestion: bool = False
151
+ controller_released: bool = False
152
+ fixed_released: bool = False
148
153
 
149
154
  @property
150
155
  def ref(self) -> queue_pb2.LeaseRef:
@@ -170,18 +175,33 @@ class Worker:
170
175
  handler: Callable[[Task, TaskContext], Awaitable[Any]],
171
176
  *,
172
177
  task_types: Iterable[str],
173
- concurrency: int,
178
+ concurrency: int | ResourceConcurrency | AdaptiveConcurrency,
174
179
  lease_duration: timedelta,
175
180
  grace_period: timedelta,
176
181
  on_idle: Callable[[], Any] | None = None,
182
+ weight: int = 1,
177
183
  ) -> None:
178
- if concurrency < 1:
179
- raise ValueError("concurrency must be >= 1")
184
+ if isinstance(concurrency, bool) or not isinstance(
185
+ concurrency, (int, ResourceConcurrency, AdaptiveConcurrency)
186
+ ):
187
+ raise ValueError(
188
+ "concurrency must be an integer, ResourceConcurrency, or AdaptiveConcurrency"
189
+ )
190
+ if isinstance(concurrency, int) and concurrency < 1:
191
+ raise ValueError("initial fixed concurrency must be >= 1")
192
+ if isinstance(weight, bool) or not isinstance(weight, int) or weight < 1:
193
+ raise ValueError("weight must be a positive integer")
180
194
  self._queue = queue
181
195
  self._client = queue._client
182
196
  self._handler = handler
183
197
  self._task_types = list(task_types)
184
- self._concurrency = concurrency
198
+ self._controller: ResourceConcurrency | AdaptiveConcurrency | None = (
199
+ concurrency if not isinstance(concurrency, int) else None
200
+ )
201
+ self._member: _Member | None = None
202
+ self._concurrency = concurrency if isinstance(concurrency, int) else max(1, concurrency.capacity)
203
+ self._configured_concurrency = self._concurrency
204
+ self._weight = weight
185
205
  self._lease_duration = lease_duration
186
206
  self._grace_period = max(0.0, grace_period.total_seconds())
187
207
  self._worker_id = self._new_worker_id()
@@ -222,6 +242,24 @@ class Worker:
222
242
  self._next_request_id = 1
223
243
  self._pending: dict[int, asyncio.Future[queue_pb2.WorkResult]] = {}
224
244
  self._inflight: dict[str, _Assignment] = {}
245
+ # Handler lifetime, not just lease-map membership, owns local capacity.
246
+ # Old-stream handlers that swallow cancellation remain here until their
247
+ # coroutine actually settles.
248
+ self._handler_tasks: set[asyncio.Task[Any]] = set()
249
+ self._fixed_running = 0
250
+ # Every capacity update shares one publisher mutex and a monotonically
251
+ # increasing session epoch. A late task from an old stream can never
252
+ # publish credit onto a replacement stream or after close.
253
+ self._publisher_lock = asyncio.Lock()
254
+ self._session_epoch = 0
255
+ self._publisher_closed = False
256
+ self._probe: tuple[_Member, int] | None = None
257
+ self._detached = False
258
+ self._controller_registered = False
259
+ self._fixed_dynamic_target = False
260
+ if self._controller is not None:
261
+ self._member = self._controller._attach(self, weight)
262
+ self._controller_registered = True
225
263
 
226
264
  @staticmethod
227
265
  def _new_worker_id() -> str:
@@ -236,8 +274,35 @@ class Worker:
236
274
 
237
275
  @property
238
276
  def concurrency(self) -> int:
277
+ """Current fixed target, or the controller's current capacity."""
278
+ if self._controller is not None:
279
+ return self._controller.capacity
239
280
  return self._concurrency
240
281
 
282
+ @property
283
+ def weight(self) -> int:
284
+ return self._weight
285
+
286
+ async def set_concurrency(self, concurrency: int) -> None:
287
+ """Resize a fixed worker without cancelling running handlers."""
288
+ if self._controller is not None:
289
+ raise ValueError("a Worker attached to a shared controller cannot resize itself")
290
+ if isinstance(concurrency, bool) or not isinstance(concurrency, int):
291
+ raise ValueError("concurrency must be an integer")
292
+ if concurrency < 0 or concurrency > 2**31 - 1:
293
+ raise ValueError("concurrency must be between 0 and 2**31-1")
294
+ if self._run_started:
295
+ conn = self._client._require_conn()
296
+ if conn._session_minor < 2:
297
+ raise IncompatibleVersionError(
298
+ "runtime fixed concurrency requires protocol minor >= 2; "
299
+ f"negotiated minor is {conn._session_minor}"
300
+ )
301
+ self._concurrency = concurrency
302
+ self._fixed_dynamic_target = True
303
+ if self._session_done is not None and not self._session_done.is_set():
304
+ await self._publish_fixed_capacity()
305
+
241
306
  async def run(self) -> None:
242
307
  """Run until :meth:`close` is called, reconnecting boundedly."""
243
308
  if self._run_started:
@@ -245,6 +310,15 @@ class Worker:
245
310
  self._run_started = True
246
311
  failures = 0
247
312
  try:
313
+ conn = self._client._require_conn()
314
+ if (
315
+ (self._controller is not None or self._fixed_dynamic_target)
316
+ and conn._session_minor < 2
317
+ ):
318
+ raise IncompatibleVersionError(
319
+ "dynamic concurrency requires protocol minor >= 2; "
320
+ f"negotiated minor is {conn._session_minor}"
321
+ )
248
322
  if self._on_idle is not None:
249
323
  # GetQueueActivity arrived in protocol minor 1; fail fast
250
324
  # instead of silently running without idle observation.
@@ -304,6 +378,9 @@ class Worker:
304
378
  async with self._close_lock:
305
379
  if self._close_task is None:
306
380
  self._close_started.set()
381
+ # The controller must linearize detach and withdraw its probe
382
+ # before this gate closes publication; the close path uses the
383
+ # still-live stream to await that correlated WorkResult.
307
384
  self._idle_candidate = False
308
385
  self._idle_zero_since = None
309
386
  self._idle_session_token = None
@@ -316,6 +393,11 @@ class Worker:
316
393
 
317
394
  async def _close_impl(self) -> None:
318
395
  try:
396
+ if self._controller is not None and self._member is not None:
397
+ await self._controller._detach(self._member)
398
+ self._detached = True
399
+ self._publisher_closed = True
400
+ self._session_epoch += 1
319
401
  session = self._session_done
320
402
  # Drain is best effort. A disconnected stream cannot safely report
321
403
  # any terminal result, and leases are deliberately left to expire.
@@ -357,7 +439,7 @@ class Worker:
357
439
  self._close_finished.set()
358
440
 
359
441
  async def _wait_handlers(self) -> None:
360
- tasks = [a.handler_task for a in self._inflight.values() if a.handler_task]
442
+ tasks = list(self._handler_tasks)
361
443
  if tasks:
362
444
  await asyncio.gather(*tasks, return_exceptions=True)
363
445
 
@@ -368,21 +450,23 @@ class Worker:
368
450
  self._idle_session_token = None
369
451
  assignments = list(self._inflight.values())
370
452
  self._inflight.clear()
453
+ renewals: list[asyncio.Task[Any]] = []
371
454
  for assignment in assignments:
372
455
  self._invalidate_assignment(assignment)
373
- tasks: list[asyncio.Task[Any]] = []
456
+ handler = assignment.handler_task
457
+ if handler is not None and not handler.done():
458
+ # Cancellation is advisory. Never let a handler that catches
459
+ # CancelledError block reconnect or forced close; its local
460
+ # capacity remains occupied until its coroutine really ends.
461
+ handler.cancel()
462
+ renewal = assignment.renew_task
463
+ if renewal is not None and not renewal.done():
464
+ renewal.cancel()
465
+ renewals.append(renewal)
466
+ if renewals:
467
+ await asyncio.gather(*renewals, return_exceptions=True)
374
468
  for assignment in assignments:
375
- if (
376
- assignment.handler_task is not None
377
- and not assignment.handler_task.done()
378
- ):
379
- assignment.handler_task.cancel()
380
- tasks.append(assignment.handler_task)
381
- if assignment.renew_task is not None and not assignment.renew_task.done():
382
- assignment.renew_task.cancel()
383
- tasks.append(assignment.renew_task)
384
- if tasks:
385
- await asyncio.gather(*tasks, return_exceptions=True)
469
+ await self._release_capacity_if_settled(assignment)
386
470
 
387
471
  def _invalidate_assignment(self, assignment: _Assignment) -> None:
388
472
  if not assignment.alive:
@@ -391,6 +475,49 @@ class Worker:
391
475
  if assignment.context is not None:
392
476
  assignment.context._cancel()
393
477
 
478
+ async def _release_controller_permit(
479
+ self,
480
+ assignment: _Assignment,
481
+ *,
482
+ success: bool = False,
483
+ congestion: bool = False,
484
+ ) -> None:
485
+ if (
486
+ self._controller is None
487
+ or self._member is None
488
+ or assignment.controller_released
489
+ ):
490
+ return
491
+ assignment.controller_released = True
492
+ await self._controller._release_running(
493
+ self._member, success=success, congestion=congestion
494
+ )
495
+
496
+ async def _release_capacity_if_settled(
497
+ self, assignment: _Assignment, *, handler_settling: bool = False
498
+ ) -> None:
499
+ """Release capacity only as the application handler settles."""
500
+ if assignment.alive:
501
+ return
502
+ handler = assignment.handler_task
503
+ current = asyncio.current_task()
504
+ if handler is not None and not handler.done():
505
+ if handler is not current or not handler_settling:
506
+ return
507
+ if self._controller is not None:
508
+ await self._release_controller_permit(
509
+ assignment,
510
+ success=assignment.completion_success,
511
+ congestion=assignment.completion_congestion,
512
+ )
513
+ return
514
+ if assignment.fixed_released:
515
+ return
516
+ assignment.fixed_released = True
517
+ self._fixed_running = max(0, self._fixed_running - 1)
518
+ if not self._close_started.is_set():
519
+ await self._publish_fixed_capacity()
520
+
394
521
  async def _run_session_monitored(self) -> None:
395
522
  """Run one Work session, observing queue activity when on_idle is set.
396
523
 
@@ -437,7 +564,7 @@ class Worker:
437
564
  ):
438
565
  return False
439
566
  # Local assignments make the queue non-idle for this worker.
440
- if self._inflight:
567
+ if self._inflight or self._handler_tasks:
441
568
  self._idle_zero_since = None
442
569
  return False
443
570
  session = self._session_done
@@ -491,6 +618,7 @@ class Worker:
491
618
  # query in-flight window cannot produce a false trigger.
492
619
  if (
493
620
  self._inflight
621
+ or self._handler_tasks
494
622
  or self._close_started.is_set()
495
623
  or self._force_cancel.is_set()
496
624
  ):
@@ -525,6 +653,7 @@ class Worker:
525
653
 
526
654
  async def _run_session(self) -> None:
527
655
  conn = self._client._require_conn()
656
+ self._session_epoch += 1
528
657
  # A new session invalidates any in-flight idle observation.
529
658
  self._idle_candidate = False
530
659
  self._idle_zero_since = None
@@ -545,7 +674,7 @@ class Worker:
545
674
  queue=self._queue.name,
546
675
  worker_id=self._worker_id,
547
676
  task_types=self._task_types,
548
- max_concurrency=self._concurrency,
677
+ max_concurrency=max(1, self._concurrency),
549
678
  )
550
679
  if self._lease_duration > timedelta(0):
551
680
  reg.lease_duration.CopyFrom(_pb.td_to_dur(self._lease_duration))
@@ -571,7 +700,11 @@ class Worker:
571
700
  if self._force_cancel.is_set():
572
701
  return
573
702
  # Initial credit is sent only after WorkerRegistered was observed.
574
- await self._send_credit(self._concurrency)
703
+ if self._controller is None:
704
+ await self._send_credit(self._concurrency)
705
+ else:
706
+ self._controller._check_loop()
707
+ await self._controller._start_member(self._member)
575
708
  if self._on_idle is not None and not self._close_started.is_set():
576
709
  # Eligible only from the first/current WorkerRegistered plus
577
710
  # a successful initial credit of this session. The candidate
@@ -628,6 +761,8 @@ class Worker:
628
761
  if not future.done():
629
762
  future.set_exception(UnavailableError("Work stream closed"))
630
763
  self._pending.clear()
764
+ if self._controller is not None and self._member is not None:
765
+ await self._controller._stream_lost(self._member)
631
766
 
632
767
  async def _enqueue_frame(self, frame: queue_pb2.WorkRequest) -> None:
633
768
  outbox = self._outbox
@@ -666,7 +801,11 @@ class Worker:
666
801
  _pb.dur_to_td(registered.lease_duration)
667
802
  or self._lease_duration
668
803
  )
669
- if registered.max_concurrency:
804
+ if (
805
+ self._controller is None
806
+ and not self._fixed_dynamic_target
807
+ and registered.max_concurrency
808
+ ):
670
809
  self._concurrency = registered.max_concurrency
671
810
  if self._registered is not None:
672
811
  self._registered.set()
@@ -722,13 +861,24 @@ class Worker:
722
861
  # existing assignment if a malformed peer sends a duplicate.
723
862
  if task.id in self._inflight:
724
863
  return
864
+ if self._controller is not None and self._member is not None:
865
+ # Consuming the reserved probe is the linearization point at which
866
+ # an assignment becomes a running controller permit.
867
+ if not await self._controller._probe_consumed(self._member):
868
+ # A withdrawn probe must not become a new handler. The lease
869
+ # remains server-owned and will naturally expire if this was a
870
+ # transport race.
871
+ return
725
872
  self._inflight[task.id] = assignment
873
+ if self._controller is None:
874
+ self._fixed_running += 1
726
875
  assignment.renew_task = asyncio.create_task(
727
876
  self._renew_loop(assignment), name=f"ringo-renew-{task.id}"
728
877
  )
729
878
  assignment.handler_task = asyncio.create_task(
730
879
  self._handle_assignment(assignment), name=f"ringo-handler-{task.id}"
731
880
  )
881
+ self._handler_tasks.add(assignment.handler_task)
732
882
  assignment.handler_task.add_done_callback(_consume_task_exception)
733
883
 
734
884
  async def _handle_assignment(self, assignment: _Assignment) -> None:
@@ -753,6 +903,13 @@ class Worker:
753
903
  except Exception as exc:
754
904
  if assignment.alive and not self._force_cancel.is_set():
755
905
  await self._finish(assignment, "nack", exc)
906
+ finally:
907
+ current = asyncio.current_task()
908
+ if current is not None:
909
+ self._handler_tasks.discard(current)
910
+ await self._release_capacity_if_settled(
911
+ assignment, handler_settling=True
912
+ )
756
913
 
757
914
  async def _finish(
758
915
  self,
@@ -811,15 +968,26 @@ class Worker:
811
968
  # protocol; leave the assignment fenced until the stream/server does.
812
969
  assignment.completion_started = False
813
970
  return
814
- await self._release_assignment(assignment)
815
- await self._send_credit(self._concurrency - len(self._inflight))
971
+ congestion = isinstance(error, RetryTaskError) and error.congestion
972
+ await self._release_assignment(
973
+ assignment,
974
+ success=operation == "ack",
975
+ congestion=congestion,
976
+ )
816
977
 
817
978
  async def _release_assignment(
818
- self, assignment: _Assignment, *, cancel_context: bool = False
979
+ self,
980
+ assignment: _Assignment,
981
+ *,
982
+ cancel_context: bool = False,
983
+ success: bool = False,
984
+ congestion: bool = False,
819
985
  ) -> None:
820
986
  if not assignment.alive:
821
987
  return
822
988
  assignment.alive = False
989
+ assignment.completion_success = success
990
+ assignment.completion_congestion = congestion
823
991
  self._inflight.pop(assignment.task.id, None)
824
992
  if cancel_context and assignment.context is not None:
825
993
  assignment.context._cancel()
@@ -829,21 +997,22 @@ class Worker:
829
997
  ):
830
998
  assignment.renew_task.cancel()
831
999
  await asyncio.gather(assignment.renew_task, return_exceptions=True)
1000
+ await self._release_capacity_if_settled(assignment)
832
1001
 
833
1002
  async def _lease_lost(self, assignment: _Assignment) -> None:
834
1003
  if not assignment.alive:
835
1004
  return
836
1005
  await self._release_assignment(assignment, cancel_context=True)
837
- current = assignment.handler_task
1006
+ handler = assignment.handler_task
838
1007
  if (
839
- current is not None
840
- and current is not asyncio.current_task()
841
- and not current.done()
1008
+ handler is not None
1009
+ and handler is not asyncio.current_task()
1010
+ and not handler.done()
842
1011
  ):
843
- current.cancel()
844
- await asyncio.gather(current, return_exceptions=True)
845
- if not self._close_started.is_set():
846
- await self._send_credit(self._concurrency - len(self._inflight))
1012
+ # Do not await an application coroutine that may intentionally
1013
+ # swallow cancellation. Its capacity is released by its finally.
1014
+ handler.cancel()
1015
+ await self._release_capacity_if_settled(assignment)
847
1016
 
848
1017
  async def _renew_loop(self, assignment: _Assignment) -> None:
849
1018
  while assignment.alive and not self._force_cancel.is_set():
@@ -905,35 +1074,169 @@ class Worker:
905
1074
  raise error_from_detail(detail)
906
1075
 
907
1076
  async def _send_credit(self, available: int) -> None:
908
- if (
909
- self._close_started.is_set()
910
- or self._session_done is None
911
- or self._session_done.is_set()
912
- ):
913
- return
914
- available = max(0, available)
915
- # The server can have an assignment queued in the transport before the
916
- # single reader materializes it locally. A very fast handler may then
917
- # briefly advertise too much capacity. The stable metadata reason lets
918
- # clients retry that exact non-mutating absolute update without parsing
919
- # human-readable error text.
1077
+ # ``available`` is intentionally not trusted across retries. A task
1078
+ # may arrive between a rejected snapshot and its retry, so every
1079
+ # credit-capacity retry samples local in-flight state again.
1080
+ await self._publish_fixed_capacity()
1081
+
1082
+ async def _publish_fixed_capacity(self) -> None:
1083
+ async with self._publisher_lock:
1084
+ epoch = self._session_epoch
1085
+ for attempt in range(20):
1086
+ if (
1087
+ self._publisher_closed
1088
+ or self._close_started.is_set()
1089
+ or epoch != self._session_epoch
1090
+ or self._session_done is None
1091
+ or self._session_done.is_set()
1092
+ ):
1093
+ return
1094
+ available = max(0, self._concurrency - self._fixed_running)
1095
+ update = queue_pb2.CreditUpdate(available=available)
1096
+ if self._fixed_dynamic_target:
1097
+ update.target_concurrency = self._concurrency
1098
+ result = await self._send_command(
1099
+ queue_pb2.WorkRequest(credit=update), wait=True
1100
+ )
1101
+ if (
1102
+ result is None
1103
+ or not result.HasField("error")
1104
+ or result.error.code == queue_pb2.ERROR_CODE_UNSPECIFIED
1105
+ ):
1106
+ return
1107
+ if (
1108
+ result.error.metadata.get("reason") != "credit_capacity"
1109
+ or attempt == 19
1110
+ ):
1111
+ raise error_from_detail(result.error)
1112
+ await asyncio.sleep(0.01)
1113
+
1114
+ async def _publish_controller_initial(self, capacity: int) -> None:
1115
+ async with self._publisher_lock:
1116
+ if (
1117
+ self._publisher_closed
1118
+ or self._close_started.is_set()
1119
+ or self._session_done is None
1120
+ or self._session_done.is_set()
1121
+ ):
1122
+ raise UnavailableError("Work stream is not connected")
1123
+ ok = await self._publish_dynamic_locked(capacity, 0)
1124
+ if not ok:
1125
+ raise UnavailableError("initial dynamic credit was not accepted")
1126
+
1127
+ async def _publish_controller_probe(
1128
+ self, member: _Member, capacity: int, generation: int
1129
+ ) -> None:
1130
+ async with self._publisher_lock:
1131
+ if (
1132
+ self._publisher_closed
1133
+ or self._close_started.is_set()
1134
+ or self._member is not member
1135
+ or not member.probe
1136
+ or member.probe_generation != generation
1137
+ or self._session_done is None
1138
+ or self._session_done.is_set()
1139
+ ):
1140
+ return
1141
+ await self._publish_dynamic_locked(
1142
+ capacity,
1143
+ 1,
1144
+ probe_member=member,
1145
+ probe_generation=generation,
1146
+ )
1147
+
1148
+ async def _publish_controller_target(self, capacity: int) -> bool:
1149
+ """Publish target/zero credit for a live shared-controller stream."""
1150
+ async with self._publisher_lock:
1151
+ if self._publisher_closed:
1152
+ return True
1153
+ if self._session_done is None or self._session_done.is_set():
1154
+ return True
1155
+ return await self._publish_dynamic_locked(capacity, 0)
1156
+
1157
+ async def _withdraw_controller_probe(
1158
+ self,
1159
+ member: _Member,
1160
+ capacity: int,
1161
+ *,
1162
+ force_target: bool = False,
1163
+ ) -> bool:
1164
+ async with self._publisher_lock:
1165
+ if self._publisher_closed:
1166
+ return True
1167
+ if self._session_done is None or self._session_done.is_set():
1168
+ # The old stream is definitively gone; its credit no longer
1169
+ # exists server-side and the controller may reclaim the token.
1170
+ return True
1171
+ return await self._publish_dynamic_locked(
1172
+ capacity,
1173
+ 0,
1174
+ allow_closing=True,
1175
+ explicit_target=force_target,
1176
+ )
1177
+
1178
+ async def _publish_dynamic_locked(
1179
+ self,
1180
+ target: int,
1181
+ available: int,
1182
+ *,
1183
+ allow_closing: bool = False,
1184
+ explicit_target: bool = False,
1185
+ probe_member: _Member | None = None,
1186
+ probe_generation: int = 0,
1187
+ ) -> bool:
1188
+ epoch = self._session_epoch
920
1189
  for attempt in range(20):
921
- request = queue_pb2.WorkRequest(
922
- credit=queue_pb2.CreditUpdate(available=available)
1190
+ if (
1191
+ self._publisher_closed
1192
+ or (self._close_started.is_set() and not allow_closing)
1193
+ or epoch != self._session_epoch
1194
+ or self._session_done is None
1195
+ or self._session_done.is_set()
1196
+ ):
1197
+ return self._session_done is None or self._session_done.is_set()
1198
+ if probe_member is not None and (
1199
+ not probe_member.probe
1200
+ or probe_member.probe_generation != probe_generation
1201
+ ):
1202
+ # The assignment consumed this probe while a previous
1203
+ # capacity update was in flight; never replay available=1.
1204
+ return True
1205
+ # target and available are one atomic protocol snapshot. A
1206
+ # credit_capacity rejection must be retried with a fresh target /
1207
+ # in-flight sample, never by replaying the old snapshot.
1208
+ current_target = (
1209
+ self._controller.capacity
1210
+ if self._controller is not None and not explicit_target
1211
+ else target
1212
+ )
1213
+ current_available = available
1214
+ if self._controller is None:
1215
+ # An assignment may have been sent by the server before the
1216
+ # rejected credit result reached this reader. Re-sample the
1217
+ # now-materialized local assignments instead of replaying the
1218
+ # stale fixed-capacity snapshot.
1219
+ current_available = max(0, current_target - self._fixed_running)
1220
+ update = queue_pb2.CreditUpdate(
1221
+ available=max(0, current_available),
1222
+ target_concurrency=current_target,
1223
+ )
1224
+ result = await self._send_command(
1225
+ queue_pb2.WorkRequest(credit=update), wait=True
923
1226
  )
924
- result = await self._send_command(request, wait=True)
925
1227
  if (
926
1228
  result is None
927
1229
  or not result.HasField("error")
928
1230
  or result.error.code == queue_pb2.ERROR_CODE_UNSPECIFIED
929
1231
  ):
930
- return
1232
+ return True
931
1233
  if (
932
1234
  result.error.metadata.get("reason") != "credit_capacity"
933
1235
  or attempt == 19
934
1236
  ):
935
1237
  raise error_from_detail(result.error)
936
1238
  await asyncio.sleep(0.01)
1239
+ return False
937
1240
 
938
1241
  def _allocate_request_id(self) -> int:
939
1242
  rid = self._next_request_id