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.
- vercel/queue/__init__.py +8 -0
- vercel/queue/__main__.py +10 -0
- vercel/queue/_internal/__init__.py +1 -0
- vercel/queue/_internal/api_async.py +219 -0
- vercel/queue/_internal/api_common.py +104 -0
- vercel/queue/_internal/api_sync.py +137 -0
- vercel/queue/_internal/asgi.py +145 -0
- vercel/queue/_internal/asynctools.py +40 -0
- vercel/queue/_internal/cli.py +313 -0
- vercel/queue/_internal/client.py +1109 -0
- vercel/queue/_internal/client_sync.py +432 -0
- vercel/queue/_internal/config.py +160 -0
- vercel/queue/_internal/constants.py +50 -0
- vercel/queue/_internal/devserver.py +205 -0
- vercel/queue/_internal/embedded.py +1900 -0
- vercel/queue/_internal/errors.py +262 -0
- vercel/queue/_internal/http.py +634 -0
- vercel/queue/_internal/lease.py +1007 -0
- vercel/queue/_internal/log.py +143 -0
- vercel/queue/_internal/messages.py +122 -0
- vercel/queue/_internal/multipart.py +255 -0
- vercel/queue/_internal/names.py +101 -0
- vercel/queue/_internal/polling.py +200 -0
- vercel/queue/_internal/push.py +246 -0
- vercel/queue/_internal/response.py +111 -0
- vercel/queue/_internal/retry.py +86 -0
- vercel/queue/_internal/streams.py +313 -0
- vercel/queue/_internal/subscribers.py +1025 -0
- vercel/queue/_internal/transports.py +363 -0
- vercel/queue/_internal/types.py +300 -0
- vercel/queue/_internal/typeutils.py +203 -0
- vercel/queue/devserver.py +24 -0
- vercel/queue/embedded.py +50 -0
- vercel/queue/py.typed +1 -0
- vercel/queue/sync.py +8 -0
- vercel/queue/testing/__init__.py +14 -0
- vercel/queue/testing/pytest.py +42 -0
- vercel/queue/testing/state.py +32 -0
- vercel/queue/version.py +3 -0
- vercel_queue-0.7.0.dist-info/METADATA +681 -0
- vercel_queue-0.7.0.dist-info/RECORD +43 -0
- vercel_queue-0.7.0.dist-info/WHEEL +4 -0
- 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))
|