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,363 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, ForwardRef, Generic, Literal, TypeAlias, TypeVar, cast
4
+
5
+ import inspect
6
+ import json
7
+ from collections.abc import AsyncIterable, AsyncIterator, Callable, Iterable
8
+ from importlib import import_module
9
+
10
+ from .constants import CONTENT_TYPE_JSON, CONTENT_TYPE_OCTET_STREAM, CONTENT_TYPE_TEXT
11
+ from .errors import SubscriptionError
12
+ from .streams import (
13
+ AsyncStreamPayload,
14
+ AsyncTextStreamPayload,
15
+ SyncStreamPayload,
16
+ SyncTextStreamPayload,
17
+ )
18
+ from .types import (
19
+ RequestContent,
20
+ Topic,
21
+ Transport,
22
+ )
23
+ from .typeutils import (
24
+ annotation_needs_resolution,
25
+ args,
26
+ is_classvar,
27
+ is_final,
28
+ is_type_var,
29
+ is_union_type,
30
+ origin_is,
31
+ strip_annotated,
32
+ )
33
+
34
+ T = TypeVar("T")
35
+
36
+ TransportKind: TypeAlias = Literal[
37
+ "json",
38
+ "byte-buffer",
39
+ "byte-stream",
40
+ "text-buffer",
41
+ "text-stream",
42
+ ]
43
+
44
+
45
+ class ByteBufferTransport:
46
+ content_type = CONTENT_TYPE_OCTET_STREAM
47
+
48
+ def serialize(self, value: bytes | bytearray | memoryview) -> bytes:
49
+ if isinstance(value, bytes):
50
+ return value
51
+ return bytes(value)
52
+
53
+ async def deserialize(
54
+ self,
55
+ payload: AsyncIterator[bytes],
56
+ *,
57
+ content_type: str,
58
+ ) -> bytes:
59
+ return await _collect_bytes_async(payload)
60
+
61
+
62
+ class TextBufferTransport:
63
+ content_type = CONTENT_TYPE_TEXT
64
+
65
+ def serialize(self, value: str) -> bytes:
66
+ return value.encode("utf-8")
67
+
68
+ async def deserialize(
69
+ self,
70
+ payload: AsyncIterator[bytes],
71
+ *,
72
+ content_type: str,
73
+ ) -> str:
74
+ return (await _collect_bytes_async(payload)).decode("utf-8")
75
+
76
+
77
+ class ByteStreamTransport:
78
+ """Transport that preserves receive payloads as one-shot byte streams.
79
+
80
+ This transport avoids buffering message bytes on receive and passes stream
81
+ payloads through to the HTTP client on send.
82
+ """
83
+
84
+ content_type = CONTENT_TYPE_OCTET_STREAM
85
+
86
+ def serialize(
87
+ self,
88
+ value: bytes
89
+ | bytearray
90
+ | memoryview
91
+ | Iterable[bytes]
92
+ | AsyncIterable[bytes]
93
+ | SyncStreamPayload
94
+ | AsyncStreamPayload,
95
+ ) -> RequestContent:
96
+ if isinstance(value, bytes):
97
+ return value
98
+ if isinstance(value, (bytearray, memoryview)):
99
+ return bytes(value)
100
+ if isinstance(value, AsyncIterable):
101
+ return value
102
+ return value
103
+
104
+ async def deserialize(
105
+ self,
106
+ payload: AsyncIterator[bytes],
107
+ *,
108
+ content_type: str,
109
+ ) -> AsyncStreamPayload:
110
+ return AsyncStreamPayload(payload)
111
+
112
+
113
+ class RawJsonTransport(Generic[T]):
114
+ content_type = CONTENT_TYPE_JSON
115
+
116
+ def __init__(
117
+ self,
118
+ *,
119
+ json_encoder: type[json.JSONEncoder] | None = None,
120
+ json_decoder: type[json.JSONDecoder] | None = None,
121
+ ) -> None:
122
+ self.json_encoder = json_encoder
123
+ self.json_decoder = json_decoder
124
+
125
+ def serialize(self, value: T) -> bytes:
126
+ if self.json_encoder is None:
127
+ return json.dumps(value).encode("utf-8")
128
+ return json.dumps(value, cls=self.json_encoder).encode("utf-8")
129
+
130
+ async def deserialize(
131
+ self,
132
+ payload: AsyncIterator[bytes],
133
+ *,
134
+ content_type: str,
135
+ ) -> T:
136
+ text = (await _collect_bytes_async(payload)).decode("utf-8")
137
+ if self.json_decoder is None:
138
+ return cast("T", json.loads(text))
139
+ return cast("T", json.loads(text, cls=self.json_decoder))
140
+
141
+
142
+ class TypedJsonTransport(Generic[T]):
143
+ content_type = CONTENT_TYPE_JSON
144
+
145
+ def __init__(self, model: type[T]) -> None:
146
+ self.model = model
147
+ try:
148
+ type_adapter = import_module("pydantic").TypeAdapter
149
+ except ImportError as exc: # pragma: no cover - depends on optional extra
150
+ raise RuntimeError("Install vercel-queue[typed] to use TypedJsonTransport") from exc
151
+ self._adapter = type_adapter(model)
152
+
153
+ def serialize(self, value: T) -> bytes:
154
+ if hasattr(value, "model_dump_json"):
155
+ return cast("Any", value).model_dump_json().encode("utf-8")
156
+ return self._adapter.dump_json(value)
157
+
158
+ async def deserialize(
159
+ self,
160
+ payload: AsyncIterator[bytes],
161
+ *,
162
+ content_type: str,
163
+ ) -> T:
164
+ return self._adapter.validate_json(await _collect_bytes_async(payload))
165
+
166
+
167
+ class _ModelDumpJsonTransport:
168
+ content_type = CONTENT_TYPE_JSON
169
+
170
+ def __init__(self, model_dump_json: Callable[[], str]) -> None:
171
+ self._model_dump_json = model_dump_json
172
+
173
+ def serialize(self, value: object) -> bytes:
174
+ del value
175
+ return self._model_dump_json().encode("utf-8")
176
+
177
+ async def deserialize(
178
+ self,
179
+ payload: AsyncIterator[bytes],
180
+ *,
181
+ content_type: str,
182
+ ) -> object:
183
+ return await RawJsonTransport[Any]().deserialize(payload, content_type=content_type)
184
+
185
+
186
+ class TextStreamTransport:
187
+ """Transport that sends and receives one-shot UTF-8 text streams."""
188
+
189
+ content_type = CONTENT_TYPE_TEXT
190
+
191
+ def serialize(
192
+ self,
193
+ value: str
194
+ | Iterable[str]
195
+ | AsyncIterable[str]
196
+ | SyncTextStreamPayload
197
+ | AsyncTextStreamPayload,
198
+ ) -> RequestContent:
199
+ if isinstance(value, str):
200
+ return value.encode("utf-8")
201
+ if isinstance(value, AsyncIterable):
202
+ return _encode_text_async(cast("AsyncIterable[str]", value))
203
+ return _encode_text_sync(value)
204
+
205
+ async def deserialize(
206
+ self,
207
+ payload: AsyncIterator[bytes],
208
+ *,
209
+ content_type: str,
210
+ ) -> AsyncTextStreamPayload:
211
+ return AsyncTextStreamPayload(payload)
212
+
213
+
214
+ def infer_send_transport(payload: object) -> object:
215
+ if model_dump_json := _model_dump_json(payload):
216
+ return _ModelDumpJsonTransport(model_dump_json)
217
+ if isinstance(payload, (bytes, bytearray, memoryview)):
218
+ return ByteBufferTransport()
219
+ if isinstance(payload, str):
220
+ return TextBufferTransport()
221
+ if isinstance(payload, (SyncTextStreamPayload, AsyncTextStreamPayload)):
222
+ return TextStreamTransport()
223
+ if isinstance(payload, (SyncStreamPayload, AsyncStreamPayload)):
224
+ return ByteStreamTransport()
225
+ return RawJsonTransport[Any]()
226
+
227
+
228
+ def is_untyped_payload_annotation(annotation: Any) -> bool:
229
+ annotation = strip_annotated(annotation)
230
+ return annotation in {inspect.Signature.empty, Any, object}
231
+
232
+
233
+ def reject_invalid_payload_annotation(annotation: Any) -> None:
234
+ annotation = strip_annotated(annotation)
235
+ if is_untyped_payload_annotation(annotation):
236
+ return
237
+ if is_type_var(annotation) or is_classvar(annotation) or is_final(annotation):
238
+ raise SubscriptionError(f"unsupported queue subscriber payload annotation: {annotation!r}")
239
+ if annotation in {list, dict, tuple, set, frozenset}:
240
+ raise SubscriptionError(
241
+ f"unsupported bare queue subscriber payload annotation: {annotation!r}"
242
+ )
243
+ if isinstance(annotation, str | ForwardRef) or origin_is(annotation, Literal):
244
+ return
245
+ if is_union_type(annotation):
246
+ for item in args(annotation):
247
+ reject_invalid_payload_annotation(item)
248
+ return
249
+ if origin_is(annotation, Iterable, AsyncIterable):
250
+ stream_args = args(annotation)
251
+ if len(stream_args) != 1 or stream_args[0] not in {bytes, str}:
252
+ raise SubscriptionError(
253
+ "queue stream subscriber annotation must be Iterable[bytes], "
254
+ "Iterable[str], AsyncIterable[bytes], or AsyncIterable[str]"
255
+ )
256
+ return
257
+ for item in args(annotation):
258
+ if item is not Ellipsis:
259
+ reject_invalid_payload_annotation(item)
260
+
261
+
262
+ def payload_transport_kind(annotation: Any) -> TransportKind:
263
+ annotation = strip_annotated(annotation)
264
+ if annotation is bytes:
265
+ return "byte-buffer"
266
+ if annotation is str:
267
+ return "text-buffer"
268
+ if annotation in {SyncStreamPayload, AsyncStreamPayload}:
269
+ return "byte-stream"
270
+ if annotation in {SyncTextStreamPayload, AsyncTextStreamPayload}:
271
+ return "text-stream"
272
+ if origin_is(annotation, Iterable, AsyncIterable):
273
+ stream_args = args(annotation)
274
+ if stream_args == (bytes,):
275
+ return "byte-stream"
276
+ if stream_args == (str,):
277
+ return "text-stream"
278
+ return "json"
279
+
280
+
281
+ def transport_for_kind(kind: TransportKind) -> Transport[Any]:
282
+ if kind == "byte-buffer":
283
+ return ByteBufferTransport()
284
+ if kind == "byte-stream":
285
+ return ByteStreamTransport()
286
+ if kind == "text-buffer":
287
+ return TextBufferTransport()
288
+ if kind == "text-stream":
289
+ return TextStreamTransport()
290
+ return RawJsonTransport[Any]()
291
+
292
+
293
+ def receive_transport_for_annotation(annotation: Any) -> Transport[Any]:
294
+ annotation = strip_annotated(annotation)
295
+ if is_untyped_payload_annotation(annotation):
296
+ return RawJsonTransport[Any]()
297
+ if annotation_needs_resolution(annotation):
298
+ raise SubscriptionError(f"unsupported queue subscriber payload annotation: {annotation!r}")
299
+ reject_invalid_payload_annotation(annotation)
300
+ kind = payload_transport_kind(annotation)
301
+ if kind == "json":
302
+ return TypedJsonTransport[Any](annotation)
303
+ return transport_for_kind(kind)
304
+
305
+
306
+ def receive_transport_for_topic(topic: object) -> Transport[Any]:
307
+ if not isinstance(topic, Topic):
308
+ return RawJsonTransport[Any]()
309
+ if topic.transport is not None:
310
+ return topic.transport
311
+ if getattr(type(topic), "__topic_origin__", None) is not Topic:
312
+ return RawJsonTransport[Any]()
313
+ return receive_transport_for_annotation(type(topic).__topic_payload_type__)
314
+
315
+
316
+ def send_transport_for_topic(topic: object) -> Transport[Any] | None:
317
+ if isinstance(topic, Topic):
318
+ return topic.transport
319
+ return None
320
+
321
+
322
+ def _model_dump_json(value: object) -> Callable[[], str] | None:
323
+ model_dump_json = getattr(value, "model_dump_json", None)
324
+ if callable(model_dump_json):
325
+ return cast("Callable[[], str]", model_dump_json)
326
+ return None
327
+
328
+
329
+ async def _collect_bytes_async(payload: AsyncIterator[bytes]) -> bytes:
330
+ chunks = bytearray()
331
+ async for chunk in payload:
332
+ chunks.extend(chunk)
333
+ return bytes(chunks)
334
+
335
+
336
+ def _encode_text_sync(payload: Iterable[str]) -> Iterable[bytes]:
337
+ for chunk in payload:
338
+ yield chunk.encode("utf-8")
339
+
340
+
341
+ async def _encode_text_async(payload: AsyncIterable[str]) -> AsyncIterator[bytes]:
342
+ async for chunk in payload:
343
+ yield chunk.encode("utf-8")
344
+
345
+
346
+ # Only add public symbols to __all__; internal helpers must stay unexported.
347
+ __all__ = (
348
+ "ByteBufferTransport",
349
+ "ByteStreamTransport",
350
+ "RawJsonTransport",
351
+ "TextBufferTransport",
352
+ "TextStreamTransport",
353
+ "TransportKind",
354
+ "TypedJsonTransport",
355
+ "infer_send_transport",
356
+ "is_untyped_payload_annotation",
357
+ "payload_transport_kind",
358
+ "receive_transport_for_annotation",
359
+ "receive_transport_for_topic",
360
+ "reject_invalid_payload_annotation",
361
+ "send_transport_for_topic",
362
+ "transport_for_kind",
363
+ )
@@ -0,0 +1,300 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, ClassVar, Generic, Protocol, TypeAlias, TypeGuard, TypeVar, cast
4
+
5
+ from collections.abc import (
6
+ AsyncIterable,
7
+ AsyncIterator,
8
+ Iterable,
9
+ Iterator,
10
+ Mapping,
11
+ )
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timedelta
14
+
15
+ from .constants import DEFAULT_RETRY_AFTER_SECONDS
16
+ from .names import SanitizedName, validate_name, validate_topic_name
17
+
18
+ T = TypeVar("T")
19
+ _TYPE_VAR_TYPE = type(T)
20
+
21
+ Duration: TypeAlias = int | float | timedelta
22
+ MessageID: TypeAlias = str
23
+ ReceiptHandle: TypeAlias = str
24
+ RequestContent: TypeAlias = bytes | Iterable[bytes] | AsyncIterable[bytes]
25
+ Headers: TypeAlias = Mapping[str, str]
26
+ RawHeaders: TypeAlias = Mapping[str, str]
27
+
28
+
29
+ class StrContainer(Protocol):
30
+ """Container of strings that excludes bare strings structurally."""
31
+
32
+ # Bare str has __contains__(str), while normal containers accept object.
33
+ # Requiring the wider signature lets type checkers reject str here.
34
+
35
+ def __iter__(self) -> Iterator[str]: ...
36
+
37
+ def __contains__(self, item: object, /) -> bool: ...
38
+
39
+
40
+ def _is_duration(value: object) -> TypeGuard[Duration]:
41
+ return isinstance(value, (int, float, timedelta)) and not isinstance(value, bool)
42
+
43
+
44
+ def duration_to_seconds(duration: Duration) -> int:
45
+ if isinstance(duration, timedelta):
46
+ return int(duration.total_seconds())
47
+ if isinstance(duration, int) and not isinstance(duration, bool):
48
+ return duration
49
+ if isinstance(duration, float):
50
+ return int(duration)
51
+ raise TypeError("duration must be an int or float number of seconds or datetime.timedelta")
52
+
53
+
54
+ def duration_to_float_seconds(duration: Duration) -> float:
55
+ if isinstance(duration, timedelta):
56
+ return duration.total_seconds()
57
+ if isinstance(duration, int) and not isinstance(duration, bool):
58
+ return float(duration)
59
+ if isinstance(duration, float):
60
+ return duration
61
+ raise TypeError("duration must be an int or float number of seconds or datetime.timedelta")
62
+
63
+
64
+ @dataclass(frozen=True, kw_only=True, eq=False)
65
+ class Topic(Generic[T]):
66
+ """A named Vercel Queues topic.
67
+
68
+ Topics identify the stream that messages are sent to and received from.
69
+ """
70
+
71
+ name: SanitizedName
72
+ """Topic name to send to or receive from."""
73
+
74
+ transport: Transport[Any] | None = None
75
+ """Optional transport used when sending to or polling this topic."""
76
+
77
+ __topic_origin__: ClassVar[type[Topic[Any]] | None] = None
78
+ __topic_payload_type__: ClassVar[Any] = None
79
+ _specializations: ClassVar[dict[Any, type[Topic[Any]]]] = {}
80
+
81
+ def __class_getitem__(cls, params: Any) -> type[Topic[Any]]:
82
+ if isinstance(params, tuple):
83
+ if len(params) != 1:
84
+ raise TypeError("Topic expects exactly one type argument")
85
+ params = params[0]
86
+ if isinstance(params, _TYPE_VAR_TYPE):
87
+ return cast("type[Topic[Any]]", cls)
88
+
89
+ try:
90
+ return cls._specializations[params]
91
+ except KeyError:
92
+ pass
93
+
94
+ payload_repr = _topic_payload_type_repr(params)
95
+ specialization = type(
96
+ f"Topic[{payload_repr}]",
97
+ (cls,),
98
+ {
99
+ "__module__": cls.__module__,
100
+ "__topic_origin__": Topic,
101
+ "__topic_payload_type__": params,
102
+ },
103
+ )
104
+ cls._specializations[params] = specialization
105
+ return cast("type[Topic[Any]]", specialization)
106
+
107
+ def __init__(
108
+ self,
109
+ name: str | SanitizedName,
110
+ *,
111
+ transport: Transport[Any] | None = None,
112
+ ) -> None:
113
+ object.__setattr__(self, "name", SanitizedName(validate_topic_name(name)))
114
+ object.__setattr__(self, "transport", transport)
115
+
116
+ def __repr__(self) -> str:
117
+ return f"Topic(name={self.name!r})"
118
+
119
+ def __eq__(self, other: object) -> bool:
120
+ if not isinstance(other, Topic):
121
+ return NotImplemented
122
+ return self.name == other.name
123
+
124
+ def __hash__(self) -> int:
125
+ return hash(self.name)
126
+
127
+
128
+ def _topic_payload_type_repr(payload_type: object) -> str:
129
+ name = getattr(payload_type, "__qualname__", None)
130
+ if isinstance(name, str):
131
+ return name
132
+ name = getattr(payload_type, "__name__", None)
133
+ if isinstance(name, str):
134
+ return name
135
+ return repr(payload_type)
136
+
137
+
138
+ @dataclass(frozen=True, kw_only=True)
139
+ class MessageMetadata:
140
+ """Metadata attached to a queue message delivery.
141
+
142
+ Identifiers and delivery tokens are opaque service values and should be
143
+ passed back unchanged when acknowledging or extending a message.
144
+ """
145
+
146
+ message_id: MessageID
147
+ """Opaque message ID assigned by the service."""
148
+
149
+ delivery_count: int
150
+ """Number of delivery attempts observed for the message."""
151
+
152
+ created_at: datetime
153
+ """Message creation timestamp."""
154
+
155
+ topic: str
156
+ """Topic name the message belongs to."""
157
+
158
+ consumer_group: SanitizedName
159
+ """Consumer group that owns this delivery."""
160
+
161
+ receipt_handle: ReceiptHandle | None = None
162
+ """Opaque delivery token used for follow-up operations."""
163
+
164
+ content_type: str | None = None
165
+ """Stored message content type."""
166
+
167
+ region: str | None = None
168
+ """Queue region for follow-up operations."""
169
+
170
+ expires_at: datetime | None = None
171
+ """Message expiration timestamp, when supplied by the service."""
172
+
173
+ visibility_deadline: datetime | None = None
174
+ """Current processing deadline, when supplied by the service."""
175
+
176
+ def __post_init__(self) -> None:
177
+ object.__setattr__(
178
+ self,
179
+ "consumer_group",
180
+ SanitizedName(validate_name(self.consumer_group, field="consumer_group")),
181
+ )
182
+
183
+
184
+ @dataclass(frozen=True, kw_only=True)
185
+ class Message(Generic[T]):
186
+ """A deserialized payload plus metadata for this delivery."""
187
+
188
+ payload: T
189
+ """Message payload returned by the configured transport."""
190
+
191
+ metadata: MessageMetadata
192
+ """Metadata for this delivery."""
193
+
194
+ @property
195
+ def message_id(self) -> MessageID:
196
+ return self.metadata.message_id
197
+
198
+
199
+ class QueueDirective(Exception): # noqa: N818
200
+ """Directive raised by a queue subscriber.
201
+
202
+ Queue delivery is at least once. Subscriber directives let a handler choose
203
+ how the SDK resolves the delivery after user code runs.
204
+ """
205
+
206
+ reason: object | None
207
+
208
+ def __init__(self, reason: object | None = None) -> None:
209
+ self.reason = reason
210
+ super().__init__(str(reason) if reason is not None else "")
211
+
212
+
213
+ class Handoff(QueueDirective):
214
+ """Directive that tells the SDK a delivery was handed off.
215
+
216
+ ``accept_and_handle`` normally owns the full push-delivery lifecycle: it
217
+ accepts the delivery, keeps the processing lease alive while subscribers
218
+ run, and acknowledges the message when all matching subscribers return
219
+ successfully. ``Handoff`` tells the SDK that subscriber code successfully
220
+ handed the delivery to another system with its own processing lifecycle.
221
+
222
+ Raising this directive stops subscriber dispatch and suppresses the SDK's
223
+ automatic follow-up action. The SDK also stops its lease renewal and leaves
224
+ the current queue lease open. External code must take over from that point:
225
+ it is responsible for any further lease renewal needed while processing
226
+ continues, and for eventually acknowledging the message or changing its
227
+ visibility using the original message metadata. If external code does none
228
+ of those things, the message becomes eligible for redelivery when the lease
229
+ expires.
230
+
231
+ This is distinct from ``RetryAfter``, which asks the SDK to update
232
+ visibility immediately, and from raising a normal exception, which reports
233
+ handler failure. For example, the Celery broker uses ``Handoff`` after a
234
+ Vercel Queue push delivery has been accepted into Kombu, because Celery
235
+ must remain responsible for the eventual ACK or reject by delivery tag.
236
+ """
237
+
238
+
239
+ class RetryAfter(QueueDirective):
240
+ """Directive that retries a message after a delay.
241
+
242
+ Raising ``RetryAfter`` stops subscriber dispatch and asks the SDK to make
243
+ the message visible again after ``delay``. The SDK keeps lifecycle ownership
244
+ through that follow-up action: it stops lease renewal, updates the delivery
245
+ visibility using the current receipt handle, and reports success to the
246
+ push-delivery caller once that update succeeds. Use this when the subscriber
247
+ cannot process the message now, but wants the SDK to schedule the next
248
+ delivery attempt instead of treating the handler as failed.
249
+
250
+ A delay of zero makes the message eligible for immediate redelivery.
251
+
252
+ Args:
253
+ delay: Retry delay in seconds or as a ``datetime.timedelta``. Defaults
254
+ to 60 seconds.
255
+ reason: Optional diagnostic value stored on the directive.
256
+
257
+ """
258
+
259
+ timeout_seconds: int
260
+
261
+ def __init__(
262
+ self,
263
+ delay: Duration = DEFAULT_RETRY_AFTER_SECONDS,
264
+ reason: object | None = None,
265
+ ) -> None:
266
+ self.timeout_seconds = duration_to_seconds(delay)
267
+ if self.timeout_seconds < 0:
268
+ raise ValueError("delay must be non-negative")
269
+ super().__init__(reason)
270
+
271
+ def __repr__(self) -> str:
272
+ return f"RetryAfter(timeout_seconds={self.timeout_seconds!r})"
273
+
274
+
275
+ class Transport(Protocol[T]):
276
+ content_type: str
277
+
278
+ def serialize(self, value: T) -> RequestContent: ...
279
+
280
+ async def deserialize(
281
+ self,
282
+ payload: AsyncIterator[bytes],
283
+ *,
284
+ content_type: str,
285
+ ) -> T: ...
286
+
287
+
288
+ # Only add public symbols to __all__; internal helpers must stay unexported.
289
+ __all__: tuple[str, ...] = (
290
+ "Duration",
291
+ "Handoff",
292
+ "Message",
293
+ "MessageID",
294
+ "MessageMetadata",
295
+ "QueueDirective",
296
+ "ReceiptHandle",
297
+ "RetryAfter",
298
+ "StrContainer",
299
+ "Topic",
300
+ )