vercel-queue 0.7.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 (43) hide show
  1. vercel/queue/__init__.py +8 -0
  2. vercel/queue/__main__.py +10 -0
  3. vercel/queue/_internal/__init__.py +1 -0
  4. vercel/queue/_internal/api_async.py +219 -0
  5. vercel/queue/_internal/api_common.py +104 -0
  6. vercel/queue/_internal/api_sync.py +137 -0
  7. vercel/queue/_internal/asgi.py +145 -0
  8. vercel/queue/_internal/asynctools.py +40 -0
  9. vercel/queue/_internal/cli.py +313 -0
  10. vercel/queue/_internal/client.py +1109 -0
  11. vercel/queue/_internal/client_sync.py +432 -0
  12. vercel/queue/_internal/config.py +160 -0
  13. vercel/queue/_internal/constants.py +50 -0
  14. vercel/queue/_internal/devserver.py +205 -0
  15. vercel/queue/_internal/embedded.py +1900 -0
  16. vercel/queue/_internal/errors.py +262 -0
  17. vercel/queue/_internal/http.py +634 -0
  18. vercel/queue/_internal/lease.py +1007 -0
  19. vercel/queue/_internal/log.py +143 -0
  20. vercel/queue/_internal/messages.py +122 -0
  21. vercel/queue/_internal/multipart.py +255 -0
  22. vercel/queue/_internal/names.py +101 -0
  23. vercel/queue/_internal/polling.py +200 -0
  24. vercel/queue/_internal/push.py +246 -0
  25. vercel/queue/_internal/response.py +111 -0
  26. vercel/queue/_internal/retry.py +86 -0
  27. vercel/queue/_internal/streams.py +313 -0
  28. vercel/queue/_internal/subscribers.py +1025 -0
  29. vercel/queue/_internal/transports.py +363 -0
  30. vercel/queue/_internal/types.py +300 -0
  31. vercel/queue/_internal/typeutils.py +203 -0
  32. vercel/queue/devserver.py +24 -0
  33. vercel/queue/embedded.py +50 -0
  34. vercel/queue/py.typed +1 -0
  35. vercel/queue/sync.py +8 -0
  36. vercel/queue/testing/__init__.py +14 -0
  37. vercel/queue/testing/pytest.py +42 -0
  38. vercel/queue/testing/state.py +32 -0
  39. vercel/queue/version.py +3 -0
  40. vercel_queue-0.7.0.dist-info/METADATA +681 -0
  41. vercel_queue-0.7.0.dist-info/RECORD +43 -0
  42. vercel_queue-0.7.0.dist-info/WHEEL +4 -0
  43. vercel_queue-0.7.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1109 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast, overload
4
+
5
+ import warnings
6
+ from collections.abc import (
7
+ AsyncIterator,
8
+ Awaitable,
9
+ Callable,
10
+ Mapping,
11
+ )
12
+ from contextlib import AsyncExitStack
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timezone
15
+ from types import TracebackType
16
+ from urllib.parse import quote
17
+
18
+ from vercel.headers import HeadersContext
19
+
20
+ from .config import (
21
+ CURRENT_DEPLOYMENT,
22
+ BaseUrl,
23
+ DeploymentOption,
24
+ apply_custom_headers,
25
+ resolve_base_url,
26
+ resolve_deployment,
27
+ resolve_region,
28
+ validate_region,
29
+ )
30
+ from .constants import (
31
+ CONTENT_TYPE_JSON,
32
+ CONTENT_TYPE_MULTIPART_MIXED,
33
+ HEADER_ACCEPT,
34
+ HEADER_AUTHORIZATION,
35
+ HEADER_CONTENT_TYPE,
36
+ HEADER_USER_AGENT,
37
+ USER_AGENT,
38
+ VQS_HEADER_CLIENT_TS,
39
+ VQS_HEADER_DELAY_SECONDS,
40
+ VQS_HEADER_DEPLOYMENT_ID,
41
+ VQS_HEADER_IDEMPOTENCY_KEY,
42
+ VQS_HEADER_MAX_MESSAGES,
43
+ VQS_HEADER_RETENTION_SECONDS,
44
+ VQS_HEADER_VISIBILITY_TIMEOUT_SECONDS,
45
+ )
46
+ from .errors import (
47
+ InvalidLimitError,
48
+ MessageNotFoundError,
49
+ MessageUnavailableError,
50
+ ProtocolError,
51
+ )
52
+ from .http import (
53
+ AsyncHttpClientFactory,
54
+ AsyncHttpMessage,
55
+ AsyncHttpResponse,
56
+ AsyncPushDeliveryBody,
57
+ AsyncPushDeliveryInput,
58
+ AsyncQueueRuntime,
59
+ BaseQueueRuntime,
60
+ PushDeliveryBody,
61
+ )
62
+ from .lease import (
63
+ DEFAULT_PROCESSING_LEASE_SECONDS,
64
+ LeaseRenewal,
65
+ finalize_payload_async,
66
+ finalize_payload_sync,
67
+ processing_lease_seconds,
68
+ retry_message_after_async,
69
+ visibility_timeout_seconds,
70
+ )
71
+ from .log import debug_log, debug_log_for_msg
72
+ from .messages import (
73
+ message_from_part_async,
74
+ message_with_region,
75
+ )
76
+ from .multipart import (
77
+ parse_multipart_messages,
78
+ )
79
+ from .names import SanitizedName, normalize_name, validate_topic_name
80
+ from .polling import poll_and_handle_async
81
+ from .push import (
82
+ accept_input,
83
+ parse_push_delivery,
84
+ parse_push_delivery_metadata,
85
+ )
86
+ from .response import queue_response_error, send_response_error
87
+ from .retry import retry_async_follow_up
88
+ from .streams import AsyncStreamPayload, AsyncTextStreamPayload
89
+ from .subscribers import (
90
+ QueueSubscriber,
91
+ call_subscribers,
92
+ infer_subscriber_transport,
93
+ )
94
+ from .transports import infer_send_transport, receive_transport_for_topic, send_transport_for_topic
95
+ from .types import (
96
+ Duration,
97
+ Handoff,
98
+ Headers,
99
+ Message,
100
+ MessageID,
101
+ MessageMetadata,
102
+ RawHeaders,
103
+ RetryAfter,
104
+ StrContainer,
105
+ Topic,
106
+ Transport,
107
+ duration_to_float_seconds,
108
+ duration_to_seconds,
109
+ )
110
+
111
+ T = TypeVar("T")
112
+ QUEUE_API_PATH = "/api/v3/topic"
113
+
114
+
115
+ if TYPE_CHECKING:
116
+ from .asgi import QueueClientAsgiApp
117
+
118
+
119
+ class _DuplicateMessageRedirect(Exception): # noqa: N818
120
+ def __init__(self, message_id: MessageID) -> None:
121
+ self.message_id = message_id
122
+ super().__init__(message_id)
123
+
124
+
125
+ class _DeferredClose:
126
+ def __init__(self) -> None:
127
+ self._close: Callable[[], Awaitable[None]] | None = None
128
+
129
+ def set(self, close: Callable[[], Awaitable[None]]) -> None:
130
+ self._close = close
131
+
132
+ async def aclose(self) -> None:
133
+ close = self._close
134
+ if close is not None:
135
+ await close()
136
+
137
+
138
+ class BaseQueueClient:
139
+ def __init__(
140
+ self,
141
+ *,
142
+ runtime: BaseQueueRuntime,
143
+ token: str | None = None,
144
+ region: str | None = None,
145
+ base_url: BaseUrl | None = None,
146
+ deployment: DeploymentOption = CURRENT_DEPLOYMENT,
147
+ headers: Mapping[str, str] | None = None,
148
+ timeout: Duration | None = 10.0,
149
+ ) -> None:
150
+ self._runtime = runtime
151
+ self.token = token
152
+ self.region = validate_region(region)
153
+ self._base_url_source = base_url
154
+ self.base_url = resolve_base_url(base_url, region=self.region)
155
+ self._runtime.configure_base_url(self.base_url)
156
+ self._resolved_region = resolve_region(self.region)
157
+ self.deployment = deployment
158
+ self.headers = dict(headers or {})
159
+ self.timeout = None if timeout is None else duration_to_float_seconds(timeout)
160
+ if self.timeout is not None and self.timeout < 0:
161
+ raise ValueError("timeout must be non-negative")
162
+
163
+ @property
164
+ def http(self) -> BaseQueueRuntime:
165
+ return self._runtime
166
+
167
+ def _url(self, *parts: str) -> str:
168
+ return self._url_from_base(self.base_url, *parts)
169
+
170
+ def _url_from_base(self, base_url: str, *parts: str) -> str:
171
+ path = "/".join(quote(part, safe="") for part in parts)
172
+ return f"{base_url}{QUEUE_API_PATH}/{path}"
173
+
174
+ def _metadata_url(self, metadata: MessageMetadata, *parts: str) -> str:
175
+ if metadata.region is None:
176
+ return self._url(*parts)
177
+ # Push callbacks are regional; follow-up ACK/lease calls must return to
178
+ # the region that owns the delivered message.
179
+ return self._url_from_base(
180
+ resolve_base_url(self._base_url_source, region=metadata.region),
181
+ *parts,
182
+ )
183
+
184
+ def _headers(
185
+ self,
186
+ *,
187
+ token: str,
188
+ content_type: str | None = None,
189
+ accept: str | None = None,
190
+ deployment: DeploymentOption = CURRENT_DEPLOYMENT,
191
+ custom_headers: Mapping[str, str] | None = None,
192
+ ) -> dict[str, str]:
193
+ headers = {
194
+ HEADER_AUTHORIZATION: f"Bearer {token}",
195
+ HEADER_USER_AGENT: USER_AGENT,
196
+ VQS_HEADER_CLIENT_TS: datetime.now(timezone.utc).isoformat(),
197
+ }
198
+ if content_type is not None:
199
+ headers[HEADER_CONTENT_TYPE] = content_type
200
+ if accept is not None:
201
+ headers[HEADER_ACCEPT] = accept
202
+ apply_custom_headers(headers, self.headers)
203
+ apply_custom_headers(headers, custom_headers)
204
+ selected_deployment = self.deployment if deployment is CURRENT_DEPLOYMENT else deployment
205
+ resolved_deployment = resolve_deployment(selected_deployment)
206
+ if resolved_deployment:
207
+ headers[VQS_HEADER_DEPLOYMENT_ID] = resolved_deployment
208
+ return headers
209
+
210
+ async def _poll_headers(self, lease_duration: Duration | None = None) -> dict[str, str]:
211
+ if lease_duration is None:
212
+ lease_duration_seconds = DEFAULT_PROCESSING_LEASE_SECONDS
213
+ else:
214
+ lease_duration_seconds = visibility_timeout_seconds(
215
+ lease_duration,
216
+ name="lease_duration",
217
+ )
218
+
219
+ headers = self._headers(
220
+ token=await self._runtime.token(self.token),
221
+ accept=CONTENT_TYPE_MULTIPART_MIXED,
222
+ )
223
+ headers[VQS_HEADER_VISIBILITY_TIMEOUT_SECONDS] = str(lease_duration_seconds)
224
+ return headers
225
+
226
+ async def _send(
227
+ self,
228
+ topic: str | SanitizedName | Topic[T],
229
+ payload: T,
230
+ *,
231
+ transport: Transport[T] | None = None,
232
+ idempotency_key: str | None = None,
233
+ retention: Duration | None = None,
234
+ delay: Duration | None = None,
235
+ deployment: DeploymentOption = CURRENT_DEPLOYMENT,
236
+ headers: Mapping[str, str] | None = None,
237
+ ) -> MessageID | None:
238
+ transport = transport or cast(
239
+ "Transport[T]",
240
+ send_transport_for_topic(topic) or infer_send_transport(payload),
241
+ )
242
+ request_headers = self._headers(
243
+ token=await self._runtime.token(self.token),
244
+ content_type=transport.content_type,
245
+ deployment=deployment,
246
+ custom_headers=headers,
247
+ )
248
+ resolved_retention = None if retention is None else duration_to_seconds(retention)
249
+ resolved_delay = None if delay is None else duration_to_seconds(delay)
250
+ if resolved_retention is not None and resolved_retention < 0:
251
+ raise ValueError("retention must be non-negative")
252
+ if resolved_delay is not None and resolved_delay < 0:
253
+ raise ValueError("delay must be non-negative")
254
+ if idempotency_key:
255
+ request_headers[VQS_HEADER_IDEMPOTENCY_KEY] = idempotency_key
256
+ if resolved_retention is not None:
257
+ request_headers[VQS_HEADER_RETENTION_SECONDS] = str(resolved_retention)
258
+ if resolved_delay is not None:
259
+ request_headers[VQS_HEADER_DELAY_SECONDS] = str(resolved_delay)
260
+ resolved_topic = validate_topic_name(topic)
261
+ response = await self._runtime.post(
262
+ self._url(resolved_topic),
263
+ content=transport.serialize(payload),
264
+ headers=request_headers,
265
+ )
266
+ if response.status_code == 202:
267
+ debug_log("send.deferred", topic=resolved_topic, status_code=202)
268
+ # Deferred ingestion intentionally has no usable message ID yet.
269
+ warnings.warn(
270
+ "message was accepted but delivery is deferred (202 Accepted)",
271
+ stacklevel=2,
272
+ )
273
+ return None
274
+ if response.status_code != 201 and (error := await send_response_error(response)):
275
+ raise error
276
+ data = response.json()
277
+ if not isinstance(data, dict) or "messageId" not in data:
278
+ raise RuntimeError("Queue API returned an unexpected response: missing 'messageId'")
279
+ return MessageID(str(data["messageId"]))
280
+
281
+ async def _receive(
282
+ self,
283
+ topic: str | SanitizedName | Topic[T],
284
+ consumer_group: str | SanitizedName,
285
+ *,
286
+ limit: int = 1,
287
+ lease_duration: Duration | None = None,
288
+ transport: Transport[T] | None = None,
289
+ ) -> AsyncIterator[Message[T]]:
290
+ if limit < 1 or limit > 10:
291
+ raise InvalidLimitError(limit)
292
+
293
+ headers = await self._poll_headers(lease_duration=lease_duration)
294
+ headers[VQS_HEADER_MAX_MESSAGES] = str(limit)
295
+
296
+ resolved_topic = validate_topic_name(topic)
297
+ consumer = normalize_name(
298
+ consumer_group,
299
+ field="consumer_group",
300
+ )
301
+ transport = transport or cast("Transport[T]", receive_transport_for_topic(topic))
302
+ async with self._runtime.stream_post(
303
+ self._url(resolved_topic, "consumer", str(consumer)),
304
+ headers=headers,
305
+ ) as response:
306
+ if response.status_code == 204:
307
+ debug_log(
308
+ "receive.empty",
309
+ topic=resolved_topic,
310
+ consumer_group=str(consumer),
311
+ status_code=204,
312
+ )
313
+ return
314
+ if error := await queue_response_error(response):
315
+ raise error
316
+
317
+ async for part_headers, body in parse_multipart_messages(response):
318
+ message = await message_from_part_async(
319
+ resolved_topic,
320
+ str(consumer),
321
+ transport,
322
+ part_headers,
323
+ body,
324
+ )
325
+ # Safety: yield-in-context-manager-in-async-generator
326
+ #
327
+ # We want to retain full streaming semantics of pulls and so
328
+ # have to keep the HTTP stream open while the callers deal
329
+ # with yielded messages. ASYNC119 correctly warns against
330
+ # yielding from an async generator, but the risk here is
331
+ # just leaking an async HTTP connection past loop lifetime,
332
+ # which is something we can live with.
333
+ yield message_with_region(message, self._resolved_region) # noqa: ASYNC119
334
+
335
+ async def _poll_by_id(
336
+ self,
337
+ topic: str | SanitizedName | Topic[T],
338
+ consumer_group: str | SanitizedName,
339
+ message_id: MessageID,
340
+ *,
341
+ lease_duration: Duration | None = None,
342
+ transport: Transport[T] | None = None,
343
+ region: str | None = None,
344
+ _seen_message_ids: frozenset[MessageID] | None = None,
345
+ ) -> Message[T]:
346
+ topic = validate_topic_name(topic)
347
+ consumer = normalize_name(
348
+ consumer_group,
349
+ field="consumer_group",
350
+ )
351
+ transport = transport or cast("Transport[T]", receive_transport_for_topic(topic))
352
+ seen_message_ids = _seen_message_ids or frozenset()
353
+ current_message_id = message_id
354
+
355
+ while True:
356
+ if current_message_id in seen_message_ids:
357
+ raise ProtocolError(
358
+ f"receive-by-id duplicate redirect loop for message {current_message_id}"
359
+ )
360
+
361
+ seen_message_ids |= {current_message_id}
362
+
363
+ try:
364
+ message = await self._poll_by_id_once(
365
+ topic,
366
+ str(consumer),
367
+ current_message_id,
368
+ lease_duration=lease_duration,
369
+ transport=transport,
370
+ region=region,
371
+ )
372
+ except _DuplicateMessageRedirect as redirect:
373
+ debug_log(
374
+ "receive.redirect_duplicate",
375
+ requested_message_id=current_message_id,
376
+ original_message_id=redirect.message_id,
377
+ )
378
+ current_message_id = redirect.message_id
379
+ continue
380
+
381
+ return message_with_region(
382
+ message,
383
+ validate_region(region) or self._resolved_region,
384
+ )
385
+
386
+ async def _poll_by_id_once(
387
+ self,
388
+ topic: str,
389
+ consumer: str,
390
+ message_id: MessageID,
391
+ *,
392
+ lease_duration: Duration | None,
393
+ transport: Transport[T],
394
+ region: str | None,
395
+ ) -> Message[T]:
396
+ headers = await self._poll_headers(lease_duration=lease_duration)
397
+
398
+ async with AsyncExitStack() as stack:
399
+ response = await stack.enter_async_context(
400
+ self._runtime.stream_post(
401
+ self._regional_url(
402
+ topic,
403
+ "consumer",
404
+ consumer,
405
+ "id",
406
+ message_id,
407
+ region=region,
408
+ ),
409
+ headers=headers,
410
+ )
411
+ )
412
+ await _raise_or_redirect_response(response, message_id)
413
+
414
+ try:
415
+ part_headers, part_body = await anext(parse_multipart_messages(response))
416
+ except StopAsyncIteration:
417
+ raise ProtocolError("multipart response contained no parts") from None
418
+
419
+ response_owner = _DeferredClose()
420
+ message = await message_from_part_async(
421
+ topic,
422
+ consumer,
423
+ transport,
424
+ part_headers,
425
+ _close_response_with_stream(part_body, response_owner.aclose),
426
+ )
427
+ if _message_payload_closes_response(message):
428
+ response_owner.set(stack.pop_all().aclose)
429
+ return message
430
+
431
+ async def _accept(
432
+ self,
433
+ raw_body: PushDeliveryBody,
434
+ headers: Headers,
435
+ *,
436
+ transport: Transport[T] | None = None,
437
+ lease_duration: Duration | None = None,
438
+ ) -> Message[T]:
439
+ parsed = await parse_push_delivery(
440
+ raw_body,
441
+ headers,
442
+ transport=transport,
443
+ )
444
+ metadata = parsed.metadata
445
+ if parsed.message is not None:
446
+ return Message(payload=parsed.message.payload, metadata=metadata)
447
+ # Header-only callbacks are notifications; fetch by ID to acquire the
448
+ # actual receipt handle before user code runs.
449
+ debug_log(
450
+ "push.header_only_fetch",
451
+ topic=metadata.topic,
452
+ consumer_group=str(metadata.consumer_group),
453
+ )
454
+ message = await self._poll_by_id(
455
+ Topic[T](metadata.topic),
456
+ metadata.consumer_group,
457
+ metadata.message_id,
458
+ lease_duration=lease_duration,
459
+ transport=transport,
460
+ region=metadata.region,
461
+ )
462
+ return message_with_region(message, metadata.region)
463
+
464
+ async def _acknowledge(self, message: Message[T] | MessageMetadata) -> None:
465
+ target = self._lease_target(message)
466
+ headers = self._headers(token=await self._runtime.token(self.token))
467
+ response = await self._runtime.delete(target.url, headers=headers)
468
+ if error := await queue_response_error(response, message_id=target.metadata.message_id):
469
+ if isinstance(error, MessageNotFoundError):
470
+ # ACK deletes the lease; a missing lease means the message is
471
+ # already settled (prior ACK or lease expiry), which is success
472
+ # as far as the caller is concerned.
473
+ debug_log_for_msg("ack.lease_already_gone", target.metadata)
474
+ return
475
+ raise error
476
+
477
+ async def _extend_lease(
478
+ self,
479
+ message: Message[T] | MessageMetadata,
480
+ duration: Duration,
481
+ *,
482
+ runtime: BaseQueueRuntime | None = None,
483
+ ) -> None:
484
+ lease_duration = visibility_timeout_seconds(duration)
485
+ target = self._lease_target(message)
486
+ send_runtime = runtime or self._runtime
487
+ headers = self._headers(
488
+ token=await send_runtime.token(self.token),
489
+ content_type=CONTENT_TYPE_JSON,
490
+ )
491
+ response = await send_runtime.patch(
492
+ target.url,
493
+ json={"visibilityTimeoutSeconds": int(lease_duration)},
494
+ headers=headers,
495
+ )
496
+ if error := await queue_response_error(response, message_id=target.metadata.message_id):
497
+ raise error
498
+
499
+ async def _renew_lease(self, message: Message[Any], duration: Duration) -> None:
500
+ await self._extend_lease(message, duration)
501
+
502
+ def _lease_target(self, message: Message[Any] | MessageMetadata) -> _LeaseTarget:
503
+ metadata = message.metadata if isinstance(message, Message) else message
504
+ if metadata.receipt_handle is None:
505
+ raise ValueError("message metadata must include receipt_handle")
506
+ return _LeaseTarget(
507
+ metadata=metadata,
508
+ url=self._metadata_url(
509
+ metadata,
510
+ metadata.topic,
511
+ "consumer",
512
+ str(metadata.consumer_group),
513
+ "lease",
514
+ metadata.receipt_handle,
515
+ ),
516
+ )
517
+
518
+ def _regional_url(self, *parts: str, region: str | None) -> str:
519
+ if region is None:
520
+ return self._url(*parts)
521
+ return self._url_from_base(resolve_base_url(self._base_url_source, region=region), *parts)
522
+
523
+ def run_lease_renewal(
524
+ self,
525
+ message: Message[Any],
526
+ lease_duration: Duration | None = None,
527
+ ) -> LeaseRenewal:
528
+ """Create a context manager that keeps a message lease alive."""
529
+ return LeaseRenewal(
530
+ message,
531
+ client=self,
532
+ lease_duration=lease_duration,
533
+ )
534
+
535
+
536
+ class Delivery(Generic[T]):
537
+ """Lifecycle wrapper returned by poll mode.
538
+
539
+ Entering a delivery starts automatic lease renewal and returns the message.
540
+ Clean exits acknowledge the message. Exceptional exits finalize payloads and
541
+ leave the message unacknowledged, except for queue directives handled by the
542
+ lifecycle.
543
+ """
544
+
545
+ def __init__(
546
+ self,
547
+ message: Message[T],
548
+ *,
549
+ client: Any,
550
+ lease_duration: Duration | None = None,
551
+ ) -> None:
552
+ self._message = message
553
+ self._client = client
554
+ self._renewal = LeaseRenewal(
555
+ message,
556
+ client=client,
557
+ lease_duration=processing_lease_seconds(lease_duration),
558
+ )
559
+ self._async_lifecycle = _AsyncMessageLifecycle(
560
+ message,
561
+ client=client,
562
+ lease_duration=lease_duration,
563
+ )
564
+ self._accepted = False
565
+ self._entered = False
566
+
567
+ def accept(self) -> Message[T]:
568
+ """Accept this delivery for manual lifecycle management.
569
+
570
+ The returned message remains leased to this consumer group, but the
571
+ delivery context manager no longer owns acknowledgement, lease renewal,
572
+ payload finalization, or retry directives. Callers that accept a
573
+ delivery must explicitly acknowledge it, extend or release its lease,
574
+ and close streaming payloads when needed.
575
+
576
+ """
577
+ if self._entered:
578
+ raise RuntimeError("entered delivery cannot be accepted")
579
+ if self._accepted:
580
+ raise RuntimeError("delivery has already been accepted")
581
+ self._accepted = True
582
+ return self._message
583
+
584
+ def _raise_if_accepted(self) -> None:
585
+ if self._accepted:
586
+ raise RuntimeError("accepted delivery cannot be used as a context manager")
587
+
588
+ def __enter__(self) -> Message[T]:
589
+ self._raise_if_accepted()
590
+ self._entered = True
591
+ self._renewal.__enter__()
592
+ return self._message
593
+
594
+ def __exit__(
595
+ self,
596
+ exc_type: type[BaseException] | None,
597
+ exc: BaseException | None,
598
+ traceback: TracebackType | None,
599
+ ) -> bool | None:
600
+ wait_for_renewal_stop = isinstance(exc, (Handoff, RetryAfter))
601
+ try:
602
+ self._renewal.stop(wait=wait_for_renewal_stop)
603
+ finally:
604
+ finalize_payload_sync(self._message.payload)
605
+
606
+ if isinstance(exc, Handoff):
607
+ debug_log_for_msg("message.handoff", self._message)
608
+ return True
609
+ if isinstance(exc, RetryAfter):
610
+ self._renewal.extend(exc.timeout_seconds)
611
+ debug_log_for_msg(
612
+ "message.retry_after",
613
+ self._message,
614
+ retry_after_seconds=exc.timeout_seconds,
615
+ )
616
+ return True
617
+ if exc is None:
618
+ # acknowledge() already retries transient failures internally.
619
+ self._client.acknowledge(self._message)
620
+ debug_log_for_msg("message.ack", self._message)
621
+ return None
622
+
623
+ async def __aenter__(self) -> Message[T]:
624
+ self._raise_if_accepted()
625
+ self._entered = True
626
+ await self._async_lifecycle.__aenter__()
627
+ return self._message
628
+
629
+ async def __aexit__(
630
+ self,
631
+ exc_type: type[BaseException] | None,
632
+ exc: BaseException | None,
633
+ traceback: TracebackType | None,
634
+ ) -> bool | None:
635
+ return await self._async_lifecycle.__aexit__(exc_type, exc, traceback)
636
+
637
+ @property
638
+ def message(self) -> Message[T]:
639
+ """Message represented by this delivery, for manual lifecycle use."""
640
+ return self._message
641
+
642
+
643
+ class QueueClient(BaseQueueClient):
644
+ """Asynchronous Vercel Queues client.
645
+
646
+ Args:
647
+ token: Vercel OIDC token. Defaults to environment/OIDC resolution.
648
+ region: Queue region, such as ``"iad1"``.
649
+ base_url: Custom service base URL or region resolver for tests or proxies.
650
+ deployment: Deployment partition selection. Use ``ALL_DEPLOYMENTS``
651
+ to omit ``Vqs-Deployment-Id``.
652
+ headers: Default custom headers for send requests.
653
+ timeout: HTTP timeout in seconds.
654
+ http_client_factory: Optional factory for the underlying HTTP client.
655
+
656
+ """
657
+
658
+ def __init__(
659
+ self,
660
+ *,
661
+ token: str | None = None,
662
+ region: str | None = None,
663
+ base_url: BaseUrl | None = None,
664
+ deployment: DeploymentOption = CURRENT_DEPLOYMENT,
665
+ headers: Mapping[str, str] | None = None,
666
+ timeout: Duration | None = 10.0,
667
+ http_client_factory: AsyncHttpClientFactory | None = None,
668
+ ) -> None:
669
+ super().__init__(
670
+ runtime=AsyncQueueRuntime(
671
+ timeout=timeout,
672
+ client_factory=http_client_factory,
673
+ ),
674
+ token=token,
675
+ region=region,
676
+ base_url=base_url,
677
+ deployment=deployment,
678
+ headers=headers,
679
+ timeout=timeout,
680
+ )
681
+
682
+ async def send(
683
+ self,
684
+ topic: str | SanitizedName | Topic[T],
685
+ payload: T,
686
+ *,
687
+ idempotency_key: str | None = None,
688
+ retention: Duration | None = None,
689
+ delay: Duration | None = None,
690
+ deployment: DeploymentOption = CURRENT_DEPLOYMENT,
691
+ headers: Mapping[str, str] | None = None,
692
+ ) -> MessageID | None:
693
+ """Send a message to a topic.
694
+
695
+ Args:
696
+ topic: Topic object or topic name.
697
+ payload: Payload accepted by the topic or inferred transport.
698
+ idempotency_key: Optional service-side deduplication key.
699
+ retention: Optional message retention duration.
700
+ delay: Optional delay before the message becomes visible.
701
+ deployment: Per-send deployment partition selection.
702
+ headers: Custom non-protected headers to include.
703
+
704
+ Returns:
705
+ Created message ID, or ``None`` if ingestion was deferred.
706
+
707
+ Raises:
708
+ QueueError: If the service rejects the request.
709
+ RuntimeError: If the service returns an unexpected success body.
710
+
711
+ """
712
+ return await self._send(
713
+ topic,
714
+ payload,
715
+ idempotency_key=idempotency_key,
716
+ retention=retention,
717
+ delay=delay,
718
+ deployment=deployment,
719
+ headers=headers,
720
+ )
721
+
722
+ async def _accept_impl(
723
+ self,
724
+ raw_body: AsyncPushDeliveryInput,
725
+ headers: RawHeaders | None = None,
726
+ *,
727
+ transport: Transport[T] | None = None,
728
+ lease_duration: Duration | None = None,
729
+ ) -> Message[T]:
730
+ raw_body, headers = accept_input(raw_body, headers)
731
+ return await self._accept(
732
+ raw_body,
733
+ headers,
734
+ transport=transport,
735
+ lease_duration=lease_duration,
736
+ )
737
+
738
+ @overload
739
+ async def accept_and_handle(
740
+ self,
741
+ raw_body: AsyncPushDeliveryBody,
742
+ headers: RawHeaders,
743
+ *,
744
+ lease_duration: Duration | None = None,
745
+ ) -> None: ...
746
+
747
+ @overload
748
+ async def accept_and_handle(
749
+ self,
750
+ raw_body: AsyncHttpMessage,
751
+ headers: None = None,
752
+ *,
753
+ lease_duration: Duration | None = None,
754
+ ) -> None: ...
755
+
756
+ async def accept_and_handle(
757
+ self,
758
+ raw_body: AsyncPushDeliveryInput,
759
+ headers: RawHeaders | None = None,
760
+ *,
761
+ lease_duration: Duration | None = None,
762
+ ) -> None:
763
+ """Accept a push callback and dispatch matching subscribers.
764
+
765
+ Args:
766
+ raw_body: Callback body bytes, byte iterable, or response object.
767
+ headers: Callback request headers, unless ``raw_body`` is a response.
768
+ lease_duration: Processing timeout used while handlers run.
769
+
770
+ Raises:
771
+ UnhandledMessageError: If no subscription matches the topic.
772
+ QueueError: If fetching, acknowledging, or retry scheduling fails.
773
+
774
+ """
775
+ await self._accept_and_handle(
776
+ raw_body,
777
+ headers,
778
+ lease_duration=lease_duration,
779
+ )
780
+
781
+ async def _accept_and_handle(
782
+ self,
783
+ raw_body: AsyncPushDeliveryInput,
784
+ headers: RawHeaders | None = None,
785
+ *,
786
+ transport: Transport[T] | None = None,
787
+ lease_duration: Duration | None = None,
788
+ ) -> None:
789
+ raw_body, headers = accept_input(raw_body, headers)
790
+ processing_lease_duration = processing_lease_seconds(lease_duration)
791
+ if transport is None:
792
+ metadata = parse_push_delivery_metadata(headers)
793
+ transport = infer_subscriber_transport(metadata)
794
+ # Delivery headers carry the auth context for follow-up queue calls,
795
+ # including lease renewals running outside this call stack.
796
+ with HeadersContext(headers).use():
797
+ message = await self._accept(
798
+ raw_body,
799
+ headers,
800
+ transport=transport,
801
+ lease_duration=processing_lease_duration,
802
+ )
803
+ lifecycle = _AsyncMessageLifecycle(
804
+ message,
805
+ client=self,
806
+ lease_duration=processing_lease_duration,
807
+ )
808
+ async with lifecycle:
809
+ await call_subscribers(message)
810
+
811
+ @overload
812
+ def poll(
813
+ self,
814
+ topic: Topic[AsyncStreamPayload],
815
+ consumer_group: str | SanitizedName,
816
+ *,
817
+ limit: int = 1,
818
+ lease_duration: Duration | None = None,
819
+ ) -> AsyncIterator[Delivery[AsyncStreamPayload]]: ...
820
+
821
+ @overload
822
+ def poll(
823
+ self,
824
+ topic: Topic[AsyncTextStreamPayload],
825
+ consumer_group: str | SanitizedName,
826
+ *,
827
+ limit: int = 1,
828
+ lease_duration: Duration | None = None,
829
+ ) -> AsyncIterator[Delivery[AsyncTextStreamPayload]]: ...
830
+
831
+ @overload
832
+ def poll(
833
+ self,
834
+ topic: Topic[T],
835
+ consumer_group: str | SanitizedName,
836
+ *,
837
+ limit: int = 1,
838
+ lease_duration: Duration | None = None,
839
+ ) -> AsyncIterator[Delivery[T]]: ...
840
+
841
+ @overload
842
+ def poll(
843
+ self,
844
+ topic: str,
845
+ consumer_group: str | SanitizedName,
846
+ *,
847
+ limit: int = 1,
848
+ lease_duration: Duration | None = None,
849
+ ) -> AsyncIterator[Delivery[Any]]: ...
850
+
851
+ @overload
852
+ def poll(
853
+ self,
854
+ topic: SanitizedName,
855
+ consumer_group: str | SanitizedName,
856
+ *,
857
+ limit: int = 1,
858
+ lease_duration: Duration | None = None,
859
+ ) -> AsyncIterator[Delivery[Any]]: ...
860
+
861
+ def poll(
862
+ self,
863
+ topic: str | SanitizedName | Topic[T],
864
+ consumer_group: str | SanitizedName,
865
+ *,
866
+ limit: int = 1,
867
+ lease_duration: Duration | None = None,
868
+ ) -> AsyncIterator[Delivery[T]]:
869
+ """Poll available deliveries for a consumer group.
870
+
871
+ Args:
872
+ topic: Topic object or topic name to receive from.
873
+ consumer_group: Consumer group to receive as.
874
+ limit: Maximum messages to claim, from 1 through 10.
875
+ lease_duration: Optional processing timeout for received messages.
876
+
877
+ Returns:
878
+ Async iterator of deliveries. Enter each delivery to process its message.
879
+
880
+ Raises:
881
+ InvalidLimitError: If ``limit`` is outside the service range.
882
+ QueueError: If the service rejects the request.
883
+ MessageCorruptedError: If a payload cannot be deserialized.
884
+
885
+ """
886
+
887
+ async def _deliveries() -> AsyncIterator[Delivery[T]]:
888
+ async for message in self._receive(
889
+ topic,
890
+ consumer_group,
891
+ limit=limit,
892
+ lease_duration=lease_duration,
893
+ ):
894
+ yield Delivery(message, client=self, lease_duration=lease_duration)
895
+
896
+ return _deliveries()
897
+
898
+ async def poll_and_handle(
899
+ self,
900
+ subscriber: QueueSubscriber[..., Any],
901
+ *,
902
+ topics: StrContainer | None = None,
903
+ interval: Duration = 1.0,
904
+ limit: int | None = None,
905
+ lease_duration: Duration | None = None,
906
+ ) -> None:
907
+ """Continuously poll messages and dispatch one registered subscriber.
908
+
909
+ Args:
910
+ subscriber: Callback previously registered with ``@subscribe``.
911
+ topics: Concrete topic names to poll. Required for wildcard or prefix
912
+ subscription patterns; exact subscriptions infer their topic.
913
+ interval: Idle backoff when all configured topics are empty.
914
+ limit: Per-request maximum from 1 through 10. ``None`` drains until
915
+ empty before idling.
916
+ lease_duration: Optional processing timeout for received messages.
917
+
918
+ """
919
+ await poll_and_handle_async(
920
+ poll=self.poll,
921
+ subscriber=subscriber,
922
+ topics=topics,
923
+ interval=interval,
924
+ limit=limit,
925
+ lease_duration=lease_duration,
926
+ )
927
+
928
+ async def acknowledge(self, message: Message[T] | MessageMetadata) -> None:
929
+ """Acknowledge a received message.
930
+
931
+ This is a lower-level API for integrations that manage message
932
+ lifecycles manually. Application code should usually process messages
933
+ through ``poll`` delivery context managers or ``accept_and_handle`` so
934
+ acknowledgement happens automatically.
935
+
936
+ Args:
937
+ message: Message or metadata containing a delivery token.
938
+
939
+ Raises:
940
+ ValueError: If delivery metadata is incomplete.
941
+ QueueError: If the service rejects the acknowledgement.
942
+
943
+ """
944
+ await retry_async_follow_up(
945
+ lambda: self._acknowledge(message),
946
+ event_prefix="ack",
947
+ )
948
+
949
+ async def extend_lease(
950
+ self,
951
+ message: Message[T] | MessageMetadata,
952
+ duration: Duration,
953
+ ) -> None:
954
+ """Extend message processing.
955
+
956
+ This is a lower-level API for integrations that manage message
957
+ lifecycles manually. Application code should usually rely on delivery
958
+ context managers or ``LeaseRenewal`` to keep leases alive while a
959
+ handler runs.
960
+
961
+ Args:
962
+ message: Message or metadata containing a delivery token.
963
+ duration: New processing timeout. Use zero to make it available again.
964
+
965
+ Raises:
966
+ ValueError: If duration or delivery metadata is invalid.
967
+ QueueError: If the service rejects the update.
968
+
969
+ """
970
+ await retry_async_follow_up(
971
+ lambda: self._extend_lease(message, duration),
972
+ event_prefix="visibility",
973
+ )
974
+
975
+ async def retry_after(
976
+ self,
977
+ message: Message[T] | MessageMetadata,
978
+ delay: Duration,
979
+ ) -> None:
980
+ """Request redelivery of a received message after ``delay``.
981
+
982
+ This is a lower-level API for integrations that manage message
983
+ lifecycles manually. Application code should usually raise
984
+ ``RetryAfter`` from a handler instead of calling this directly.
985
+
986
+ Unlike ``extend_lease``, this is a settlement-style follow-up:
987
+ transient failures are retried, and a lease that no longer exists is
988
+ tolerated with a warning because the message will be redelivered
989
+ anyway.
990
+
991
+ Args:
992
+ message: Message or metadata containing a delivery token.
993
+ delay: Time until the message becomes available again.
994
+
995
+ Raises:
996
+ ValueError: If delay or delivery metadata is invalid.
997
+ QueueError: If the service rejects the update.
998
+
999
+ """
1000
+ await retry_message_after_async(message, delay, self._extend_lease)
1001
+
1002
+ def asgi_app(self) -> QueueClientAsgiApp:
1003
+ """Return an ASGI push-callback app backed by this client."""
1004
+ from .asgi import QueueClientAsgiApp # noqa: PLC0415
1005
+
1006
+ return QueueClientAsgiApp(self)
1007
+
1008
+
1009
+ @dataclass(frozen=True, kw_only=True)
1010
+ class _LeaseTarget:
1011
+ metadata: MessageMetadata
1012
+ url: str
1013
+
1014
+
1015
+ class _AsyncMessageLifecycle:
1016
+ def __init__(
1017
+ self,
1018
+ message: Message[Any],
1019
+ *,
1020
+ client: QueueClient,
1021
+ lease_duration: Duration | None = None,
1022
+ ) -> None:
1023
+ self._message = message
1024
+ self._client = client
1025
+ self._renewal = LeaseRenewal(
1026
+ message,
1027
+ client=client,
1028
+ lease_duration=processing_lease_seconds(lease_duration),
1029
+ )
1030
+
1031
+ async def __aenter__(self) -> LeaseRenewal:
1032
+ await self._renewal.start_async()
1033
+ return self._renewal
1034
+
1035
+ async def __aexit__(
1036
+ self,
1037
+ exc_type: type[BaseException] | None,
1038
+ exc: BaseException | None,
1039
+ traceback: TracebackType | None,
1040
+ ) -> bool | None:
1041
+ # ACK, Handoff, and RetryAfter deliberately use different renewal shutdown
1042
+ # semantics. Server-side, background renewal and final directives are
1043
+ # all conditional writes against the same active lease record. If a
1044
+ # renewal races with ACK, the final state is safe either way: renewal
1045
+ # first still leaves the same receipt handle valid for ACK, and ACK
1046
+ # first makes the later renewal fail with a 4xx because the message is
1047
+ # no longer INFLIGHT. Waiting for cancellation in the success path only
1048
+ # adds tail latency without improving correctness.
1049
+ #
1050
+ # Handoff leaves the delivery open for another owner, so a best-effort
1051
+ # stop failure can keep the hidden renewal alive past handoff.
1052
+ #
1053
+ # RetryAfter is different because it is itself a visibility update. A
1054
+ # late automatic renewal can overwrite the shorter retry delay the
1055
+ # handler requested, delaying redelivery until the normal processing
1056
+ # lease. For that path we wait for the renewal worker to finish or
1057
+ # cancel any in-flight extension before applying the RetryAfter
1058
+ # visibility change. If stopping times out, we still apply the
1059
+ # directive: the worst remaining case is the pre-existing race, while
1060
+ # refusing to apply RetryAfter would always leave the long lease behind.
1061
+ wait_for_renewal_stop = isinstance(exc, (Handoff, RetryAfter))
1062
+ try:
1063
+ await self._renewal.stop_async(wait=wait_for_renewal_stop)
1064
+ finally:
1065
+ await finalize_payload_async(self._message.payload)
1066
+
1067
+ if isinstance(exc, Handoff):
1068
+ debug_log_for_msg("message.handoff", self._message)
1069
+ return True
1070
+ if isinstance(exc, RetryAfter):
1071
+ await self._renewal.extend_async(exc.timeout_seconds)
1072
+ debug_log_for_msg(
1073
+ "message.retry_after",
1074
+ self._message,
1075
+ retry_after_seconds=exc.timeout_seconds,
1076
+ )
1077
+ return True
1078
+ if exc is None:
1079
+ # acknowledge() already retries transient failures internally.
1080
+ await self._client.acknowledge(self._message)
1081
+ debug_log_for_msg("message.ack", self._message)
1082
+ return None
1083
+
1084
+
1085
+ async def _close_response_with_stream(
1086
+ body: AsyncIterator[bytes],
1087
+ close_response: Callable[[], Awaitable[None]],
1088
+ ) -> AsyncIterator[bytes]:
1089
+ try:
1090
+ async for chunk in body:
1091
+ yield chunk
1092
+ finally:
1093
+ await close_response()
1094
+
1095
+
1096
+ async def _raise_or_redirect_response(
1097
+ response: AsyncHttpResponse,
1098
+ message_id: MessageID,
1099
+ ) -> None:
1100
+ if error := await queue_response_error(response, message_id=message_id):
1101
+ if isinstance(error, MessageUnavailableError) and error.original_message_id:
1102
+ original_message_id = error.original_message_id
1103
+ if original_message_id != message_id:
1104
+ raise _DuplicateMessageRedirect(original_message_id) from error
1105
+ raise error
1106
+
1107
+
1108
+ def _message_payload_closes_response(message: Message[Any]) -> bool:
1109
+ return isinstance(message.payload, (AsyncStreamPayload, AsyncTextStreamPayload))