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
vercel/queue/__init__.py
ADDED
vercel/queue/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Internal implementation for vercel.queue."""
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, TypeVar, overload
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Mapping
|
|
6
|
+
|
|
7
|
+
from .client import Delivery, LeaseRenewal, QueueClient
|
|
8
|
+
from .config import (
|
|
9
|
+
CURRENT_DEPLOYMENT,
|
|
10
|
+
DeploymentOption,
|
|
11
|
+
)
|
|
12
|
+
from .http import (
|
|
13
|
+
AsyncHttpMessage,
|
|
14
|
+
AsyncPushDeliveryBody,
|
|
15
|
+
)
|
|
16
|
+
from .names import SanitizedName
|
|
17
|
+
from .subscribers import QueueSubscriber
|
|
18
|
+
from .types import (
|
|
19
|
+
Duration,
|
|
20
|
+
MessageID,
|
|
21
|
+
RawHeaders,
|
|
22
|
+
StrContainer,
|
|
23
|
+
Topic,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def send(
|
|
30
|
+
topic: str | SanitizedName | Topic[T],
|
|
31
|
+
payload: T,
|
|
32
|
+
*,
|
|
33
|
+
idempotency_key: str | None = None,
|
|
34
|
+
retention: Duration | None = None,
|
|
35
|
+
delay: Duration | None = None,
|
|
36
|
+
deployment: DeploymentOption = CURRENT_DEPLOYMENT,
|
|
37
|
+
headers: Mapping[str, str] | None = None,
|
|
38
|
+
) -> MessageID | None:
|
|
39
|
+
"""Send a message with the default asynchronous client.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
topic: Topic object or topic name.
|
|
43
|
+
payload: Payload accepted by the topic or inferred transport.
|
|
44
|
+
idempotency_key: Optional service-side deduplication key.
|
|
45
|
+
retention: Optional message retention duration.
|
|
46
|
+
delay: Optional delay before the message becomes visible.
|
|
47
|
+
deployment: Per-send deployment partition selection.
|
|
48
|
+
headers: Custom non-protected headers to include.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Created message ID, or ``None`` if ingestion was deferred.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
QueueError: If the service rejects the request.
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
client = QueueClient()
|
|
58
|
+
return await client.send(
|
|
59
|
+
topic,
|
|
60
|
+
payload,
|
|
61
|
+
idempotency_key=idempotency_key,
|
|
62
|
+
retention=retention,
|
|
63
|
+
delay=delay,
|
|
64
|
+
deployment=deployment,
|
|
65
|
+
headers=headers,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@overload
|
|
70
|
+
async def accept_and_handle(
|
|
71
|
+
raw_body: AsyncPushDeliveryBody,
|
|
72
|
+
headers: RawHeaders,
|
|
73
|
+
*,
|
|
74
|
+
lease_duration: Duration | None = None,
|
|
75
|
+
) -> None: ...
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@overload
|
|
79
|
+
async def accept_and_handle(
|
|
80
|
+
raw_body: AsyncHttpMessage,
|
|
81
|
+
headers: None = None,
|
|
82
|
+
*,
|
|
83
|
+
lease_duration: Duration | None = None,
|
|
84
|
+
) -> None: ...
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def accept_and_handle(
|
|
88
|
+
raw_body: AsyncPushDeliveryBody | AsyncHttpMessage,
|
|
89
|
+
headers: RawHeaders | None = None,
|
|
90
|
+
*,
|
|
91
|
+
lease_duration: Duration | None = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Accept a push callback and dispatch async subscribers.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
raw_body: Callback body bytes, byte iterable, or response object.
|
|
97
|
+
headers: Callback request headers, unless ``raw_body`` is a response.
|
|
98
|
+
lease_duration: Processing timeout used while handlers run.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
UnhandledMessageError: If no subscription matches the topic.
|
|
102
|
+
QueueError: If fetching, acknowledging, or retry scheduling fails.
|
|
103
|
+
|
|
104
|
+
"""
|
|
105
|
+
client = QueueClient()
|
|
106
|
+
await client._accept_and_handle( # noqa: SLF001
|
|
107
|
+
raw_body,
|
|
108
|
+
headers,
|
|
109
|
+
lease_duration=lease_duration,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@overload
|
|
114
|
+
def poll(
|
|
115
|
+
topic: Topic[T],
|
|
116
|
+
consumer_group: str | SanitizedName,
|
|
117
|
+
*,
|
|
118
|
+
limit: int = 1,
|
|
119
|
+
lease_duration: Duration | None = None,
|
|
120
|
+
) -> AsyncIterator[Delivery[T]]: ...
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@overload
|
|
124
|
+
def poll(
|
|
125
|
+
topic: str,
|
|
126
|
+
consumer_group: str | SanitizedName,
|
|
127
|
+
*,
|
|
128
|
+
limit: int = 1,
|
|
129
|
+
lease_duration: Duration | None = None,
|
|
130
|
+
) -> AsyncIterator[Delivery[Any]]: ...
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@overload
|
|
134
|
+
def poll(
|
|
135
|
+
topic: SanitizedName,
|
|
136
|
+
consumer_group: str | SanitizedName,
|
|
137
|
+
*,
|
|
138
|
+
limit: int = 1,
|
|
139
|
+
lease_duration: Duration | None = None,
|
|
140
|
+
) -> AsyncIterator[Delivery[Any]]: ...
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def poll(
|
|
144
|
+
topic: str | SanitizedName | Topic[T],
|
|
145
|
+
consumer_group: str | SanitizedName,
|
|
146
|
+
*,
|
|
147
|
+
limit: int = 1,
|
|
148
|
+
lease_duration: Duration | None = None,
|
|
149
|
+
) -> AsyncIterator[Delivery[T]]:
|
|
150
|
+
"""Poll available deliveries with the default asynchronous client.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
topic: Topic object or topic name to receive from.
|
|
154
|
+
consumer_group: Consumer group to receive as.
|
|
155
|
+
limit: Maximum messages to claim, from 1 through 10.
|
|
156
|
+
lease_duration: Optional processing timeout for received messages.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Async iterator of deliveries. Enter each delivery to process its message.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
InvalidLimitError: If ``limit`` is outside the service range.
|
|
163
|
+
QueueError: If the service rejects the request.
|
|
164
|
+
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
async def _iterate() -> AsyncIterator[Delivery[T]]:
|
|
168
|
+
client = QueueClient()
|
|
169
|
+
async for delivery in client.poll(
|
|
170
|
+
topic,
|
|
171
|
+
consumer_group,
|
|
172
|
+
limit=limit,
|
|
173
|
+
lease_duration=lease_duration,
|
|
174
|
+
):
|
|
175
|
+
yield delivery
|
|
176
|
+
|
|
177
|
+
return _iterate()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def poll_and_handle(
|
|
181
|
+
subscriber: QueueSubscriber[..., Any],
|
|
182
|
+
*,
|
|
183
|
+
topics: StrContainer | None = None,
|
|
184
|
+
interval: Duration = 1.0,
|
|
185
|
+
limit: int | None = None,
|
|
186
|
+
lease_duration: Duration | None = None,
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Continuously poll messages with the default async client.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
subscriber: Callback previously registered with ``@subscribe``.
|
|
192
|
+
topics: Concrete topic names to poll. Required for wildcard or prefix
|
|
193
|
+
subscription patterns; exact subscriptions infer their topic.
|
|
194
|
+
interval: Idle backoff when all configured topics are empty.
|
|
195
|
+
limit: Per-request maximum from 1 through 10. ``None`` drains until
|
|
196
|
+
empty before idling.
|
|
197
|
+
lease_duration: Optional processing timeout for received messages.
|
|
198
|
+
|
|
199
|
+
"""
|
|
200
|
+
client = QueueClient()
|
|
201
|
+
await client.poll_and_handle(
|
|
202
|
+
subscriber,
|
|
203
|
+
topics=topics,
|
|
204
|
+
interval=interval,
|
|
205
|
+
limit=limit,
|
|
206
|
+
lease_duration=lease_duration,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# Only add public symbols to __all__; internal helpers must stay unexported.
|
|
211
|
+
__all__: tuple[str, ...] = (
|
|
212
|
+
"Delivery",
|
|
213
|
+
"LeaseRenewal",
|
|
214
|
+
"QueueClient",
|
|
215
|
+
"accept_and_handle",
|
|
216
|
+
"poll",
|
|
217
|
+
"poll_and_handle",
|
|
218
|
+
"send",
|
|
219
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Common public Vercel Queue API exports."""
|
|
2
|
+
# ruff: noqa: F403, F405
|
|
3
|
+
|
|
4
|
+
from .. import version as _version
|
|
5
|
+
from . import (
|
|
6
|
+
asgi as _asgi,
|
|
7
|
+
config as _config,
|
|
8
|
+
names as _names,
|
|
9
|
+
transports as _transports,
|
|
10
|
+
types as _types,
|
|
11
|
+
)
|
|
12
|
+
from .errors import *
|
|
13
|
+
from .subscribers import QueueSubscriber, Subscription, get_subscriptions, subscribe
|
|
14
|
+
|
|
15
|
+
ALL_DEPLOYMENTS = _config.ALL_DEPLOYMENTS
|
|
16
|
+
CURRENT_DEPLOYMENT = _config.CURRENT_DEPLOYMENT
|
|
17
|
+
AllDeployments = _config.AllDeployments
|
|
18
|
+
ByteBufferTransport = _transports.ByteBufferTransport
|
|
19
|
+
ByteStreamTransport = _transports.ByteStreamTransport
|
|
20
|
+
CurrentDeployment = _config.CurrentDeployment
|
|
21
|
+
DeploymentID = _config.DeploymentID
|
|
22
|
+
DeploymentOption = _config.DeploymentOption
|
|
23
|
+
Duration = _types.Duration
|
|
24
|
+
Handoff = _types.Handoff
|
|
25
|
+
Message = _types.Message
|
|
26
|
+
MessageID = _types.MessageID
|
|
27
|
+
MessageMetadata = _types.MessageMetadata
|
|
28
|
+
QueueClientAsgiApp = _asgi.QueueClientAsgiApp
|
|
29
|
+
QueueDirective = _types.QueueDirective
|
|
30
|
+
RawJsonTransport = _transports.RawJsonTransport
|
|
31
|
+
ReceiptHandle = _types.ReceiptHandle
|
|
32
|
+
RetryAfter = _types.RetryAfter
|
|
33
|
+
SanitizedName = _names.SanitizedName
|
|
34
|
+
StrContainer = _types.StrContainer
|
|
35
|
+
TextBufferTransport = _transports.TextBufferTransport
|
|
36
|
+
TextStreamTransport = _transports.TextStreamTransport
|
|
37
|
+
Topic = _types.Topic
|
|
38
|
+
TypedJsonTransport = _transports.TypedJsonTransport
|
|
39
|
+
__version__ = _version.__version__
|
|
40
|
+
asgi_app = _asgi.asgi_app
|
|
41
|
+
sanitize_name = _names.sanitize_name
|
|
42
|
+
|
|
43
|
+
# Only add public symbols to __all__; internal helpers must stay unexported.
|
|
44
|
+
__all__ = (
|
|
45
|
+
"ALL_DEPLOYMENTS",
|
|
46
|
+
"CURRENT_DEPLOYMENT",
|
|
47
|
+
"AllDeployments",
|
|
48
|
+
"BadRequestError",
|
|
49
|
+
"ByteBufferTransport",
|
|
50
|
+
"ByteStreamTransport",
|
|
51
|
+
"CommunicationError",
|
|
52
|
+
"ConsumerDiscoveryError",
|
|
53
|
+
"ConsumerRegistryNotConfiguredError",
|
|
54
|
+
"CurrentDeployment",
|
|
55
|
+
"DeploymentID",
|
|
56
|
+
"DeploymentOption",
|
|
57
|
+
"DeploymentResolutionError",
|
|
58
|
+
"DuplicateIdempotencyKeyError",
|
|
59
|
+
"DuplicateSubscriptionError",
|
|
60
|
+
"Duration",
|
|
61
|
+
"ForbiddenError",
|
|
62
|
+
"Handoff",
|
|
63
|
+
"InternalServerError",
|
|
64
|
+
"InvalidLimitError",
|
|
65
|
+
"Message",
|
|
66
|
+
"MessageAlreadyProcessedError",
|
|
67
|
+
"MessageCorruptedError",
|
|
68
|
+
"MessageID",
|
|
69
|
+
"MessageLeaseExpiredError",
|
|
70
|
+
"MessageLockedError",
|
|
71
|
+
"MessageMetadata",
|
|
72
|
+
"MessageNotFoundError",
|
|
73
|
+
"MessageNotInFlightError",
|
|
74
|
+
"MessageUnavailableError",
|
|
75
|
+
"PayloadValidationError",
|
|
76
|
+
"ProtocolError",
|
|
77
|
+
"QueueClientAsgiApp",
|
|
78
|
+
"QueueDirective",
|
|
79
|
+
"QueueError",
|
|
80
|
+
"QueueSubscriber",
|
|
81
|
+
"RawJsonTransport",
|
|
82
|
+
"ReceiptHandle",
|
|
83
|
+
"ReceiptHandleMismatchError",
|
|
84
|
+
"RetryAfter",
|
|
85
|
+
"RetryableError",
|
|
86
|
+
"SanitizedName",
|
|
87
|
+
"ServiceError",
|
|
88
|
+
"StrContainer",
|
|
89
|
+
"Subscription",
|
|
90
|
+
"SubscriptionError",
|
|
91
|
+
"TextBufferTransport",
|
|
92
|
+
"TextStreamTransport",
|
|
93
|
+
"ThrottledError",
|
|
94
|
+
"TokenResolutionError",
|
|
95
|
+
"Topic",
|
|
96
|
+
"TypedJsonTransport",
|
|
97
|
+
"UnauthorizedError",
|
|
98
|
+
"UnhandledMessageError",
|
|
99
|
+
"__version__",
|
|
100
|
+
"asgi_app",
|
|
101
|
+
"get_subscriptions",
|
|
102
|
+
"sanitize_name",
|
|
103
|
+
"subscribe",
|
|
104
|
+
)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, TypeVar, overload
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Mapping
|
|
6
|
+
|
|
7
|
+
from .client import Delivery
|
|
8
|
+
from .client_sync import LeaseRenewal, QueueClient
|
|
9
|
+
from .config import CURRENT_DEPLOYMENT, DeploymentOption
|
|
10
|
+
from .http import HttpResponse, PushDeliveryBody
|
|
11
|
+
from .names import SanitizedName
|
|
12
|
+
from .types import (
|
|
13
|
+
Duration,
|
|
14
|
+
MessageID,
|
|
15
|
+
RawHeaders,
|
|
16
|
+
Topic,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def send(
|
|
23
|
+
topic: str | SanitizedName | Topic[T],
|
|
24
|
+
payload: T,
|
|
25
|
+
*,
|
|
26
|
+
idempotency_key: str | None = None,
|
|
27
|
+
retention: Duration | None = None,
|
|
28
|
+
delay: Duration | None = None,
|
|
29
|
+
deployment: DeploymentOption = CURRENT_DEPLOYMENT,
|
|
30
|
+
headers: Mapping[str, str] | None = None,
|
|
31
|
+
) -> MessageID | None:
|
|
32
|
+
"""Send a message with the default synchronous client."""
|
|
33
|
+
client = QueueClient()
|
|
34
|
+
return client.send(
|
|
35
|
+
topic,
|
|
36
|
+
payload,
|
|
37
|
+
idempotency_key=idempotency_key,
|
|
38
|
+
retention=retention,
|
|
39
|
+
delay=delay,
|
|
40
|
+
deployment=deployment,
|
|
41
|
+
headers=headers,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@overload
|
|
46
|
+
def accept_and_handle(
|
|
47
|
+
raw_body: PushDeliveryBody,
|
|
48
|
+
headers: RawHeaders,
|
|
49
|
+
*,
|
|
50
|
+
lease_duration: Duration | None = None,
|
|
51
|
+
) -> None: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@overload
|
|
55
|
+
def accept_and_handle(
|
|
56
|
+
raw_body: HttpResponse,
|
|
57
|
+
headers: None = None,
|
|
58
|
+
*,
|
|
59
|
+
lease_duration: Duration | None = None,
|
|
60
|
+
) -> None: ...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def accept_and_handle(
|
|
64
|
+
raw_body: PushDeliveryBody | HttpResponse,
|
|
65
|
+
headers: RawHeaders | None = None,
|
|
66
|
+
*,
|
|
67
|
+
lease_duration: Duration | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Accept a push callback and dispatch sync subscribers."""
|
|
70
|
+
client = QueueClient()
|
|
71
|
+
client._accept_and_handle( # noqa: SLF001
|
|
72
|
+
raw_body,
|
|
73
|
+
headers,
|
|
74
|
+
lease_duration=lease_duration,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@overload
|
|
79
|
+
def poll(
|
|
80
|
+
topic: Topic[T],
|
|
81
|
+
consumer_group: str | SanitizedName,
|
|
82
|
+
*,
|
|
83
|
+
limit: int = 1,
|
|
84
|
+
lease_duration: Duration | None = None,
|
|
85
|
+
) -> Iterator[Delivery[T]]: ...
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@overload
|
|
89
|
+
def poll(
|
|
90
|
+
topic: str,
|
|
91
|
+
consumer_group: str | SanitizedName,
|
|
92
|
+
*,
|
|
93
|
+
limit: int = 1,
|
|
94
|
+
lease_duration: Duration | None = None,
|
|
95
|
+
) -> Iterator[Delivery[Any]]: ...
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@overload
|
|
99
|
+
def poll(
|
|
100
|
+
topic: SanitizedName,
|
|
101
|
+
consumer_group: str | SanitizedName,
|
|
102
|
+
*,
|
|
103
|
+
limit: int = 1,
|
|
104
|
+
lease_duration: Duration | None = None,
|
|
105
|
+
) -> Iterator[Delivery[Any]]: ...
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def poll(
|
|
109
|
+
topic: str | SanitizedName | Topic[Any],
|
|
110
|
+
consumer_group: str | SanitizedName,
|
|
111
|
+
*,
|
|
112
|
+
limit: int = 1,
|
|
113
|
+
lease_duration: Duration | None = None,
|
|
114
|
+
) -> Iterator[Delivery[Any]]:
|
|
115
|
+
"""Poll available deliveries with the default synchronous client."""
|
|
116
|
+
|
|
117
|
+
def _iterate() -> Iterator[Delivery[Any]]:
|
|
118
|
+
client = QueueClient()
|
|
119
|
+
yield from client.poll(
|
|
120
|
+
topic,
|
|
121
|
+
consumer_group,
|
|
122
|
+
limit=limit,
|
|
123
|
+
lease_duration=lease_duration,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return _iterate()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# Only add public symbols to __all__; internal helpers must stay unexported.
|
|
130
|
+
__all__ = (
|
|
131
|
+
"Delivery",
|
|
132
|
+
"LeaseRenewal",
|
|
133
|
+
"QueueClient",
|
|
134
|
+
"accept_and_handle",
|
|
135
|
+
"poll",
|
|
136
|
+
"send",
|
|
137
|
+
)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, TypeAlias, TypedDict
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
|
|
7
|
+
|
|
8
|
+
from vercel.headers import HeadersContext, headers_from_asgi_scope
|
|
9
|
+
|
|
10
|
+
from .client import QueueClient
|
|
11
|
+
from .config import CURRENT_DEPLOYMENT, BaseUrl, DeploymentOption
|
|
12
|
+
from .errors import ProtocolError
|
|
13
|
+
from .http import AsyncHttpClientFactory
|
|
14
|
+
from .log import configure_asgi_logger, debug_log
|
|
15
|
+
from .types import Duration
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("vercel.queue")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AsgiScope(TypedDict, total=False):
|
|
21
|
+
type: str
|
|
22
|
+
method: str
|
|
23
|
+
headers: list[tuple[bytes, bytes]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
AsgiMessage: TypeAlias = dict[str, Any]
|
|
27
|
+
AsgiReceive: TypeAlias = Callable[[], Awaitable[AsgiMessage]]
|
|
28
|
+
AsgiSend: TypeAlias = Callable[[AsgiMessage], Awaitable[None]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class QueueClientAsgiApp:
|
|
32
|
+
"""ASGI push-callback app backed by an async queue client."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, client: QueueClient) -> None:
|
|
35
|
+
configure_asgi_logger()
|
|
36
|
+
self.client = client
|
|
37
|
+
|
|
38
|
+
async def __call__(
|
|
39
|
+
self,
|
|
40
|
+
scope: AsgiScope,
|
|
41
|
+
receive: AsgiReceive,
|
|
42
|
+
send: AsgiSend,
|
|
43
|
+
) -> None:
|
|
44
|
+
scope_type = scope.get("type")
|
|
45
|
+
if scope_type == "http":
|
|
46
|
+
await self._handle_http(scope, receive, send)
|
|
47
|
+
return
|
|
48
|
+
if scope_type == "lifespan":
|
|
49
|
+
await self._handle_lifespan(receive, send)
|
|
50
|
+
return
|
|
51
|
+
raise RuntimeError(f"Unsupported ASGI scope type: {scope_type!r}")
|
|
52
|
+
|
|
53
|
+
async def _handle_http(
|
|
54
|
+
self,
|
|
55
|
+
scope: AsgiScope,
|
|
56
|
+
receive: AsgiReceive,
|
|
57
|
+
send: AsgiSend,
|
|
58
|
+
) -> None:
|
|
59
|
+
if scope.get("method") != "POST":
|
|
60
|
+
await _send_status(send, 405)
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
headers = headers_from_asgi_scope(scope)
|
|
64
|
+
try:
|
|
65
|
+
with HeadersContext(headers).use():
|
|
66
|
+
await self.client.accept_and_handle(
|
|
67
|
+
_body_chunks(receive),
|
|
68
|
+
headers,
|
|
69
|
+
)
|
|
70
|
+
except (ProtocolError, TypeError, ValueError) as exc:
|
|
71
|
+
debug_log("asgi.bad_request", error=repr(exc))
|
|
72
|
+
logger.warning("Vercel Queue push callback rejected: %s", exc)
|
|
73
|
+
await _send_status(send, 400)
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
debug_log("asgi.delivery_failure", error=repr(exc))
|
|
76
|
+
logger.exception("Vercel Queue push callback failed")
|
|
77
|
+
await _send_status(send, 500)
|
|
78
|
+
else:
|
|
79
|
+
await _send_status(send, 204)
|
|
80
|
+
|
|
81
|
+
async def _handle_lifespan(self, receive: AsgiReceive, send: AsgiSend) -> None:
|
|
82
|
+
while True:
|
|
83
|
+
message = await receive()
|
|
84
|
+
message_type = message.get("type")
|
|
85
|
+
if message_type == "lifespan.startup":
|
|
86
|
+
await send({"type": "lifespan.startup.complete"})
|
|
87
|
+
elif message_type == "lifespan.shutdown":
|
|
88
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def asgi_app(
|
|
93
|
+
*,
|
|
94
|
+
client: QueueClient | None = None,
|
|
95
|
+
token: str | None = None,
|
|
96
|
+
region: str | None = None,
|
|
97
|
+
base_url: BaseUrl | None = None,
|
|
98
|
+
deployment: DeploymentOption = CURRENT_DEPLOYMENT,
|
|
99
|
+
headers: Mapping[str, str] | None = None,
|
|
100
|
+
timeout: Duration | None = 10.0,
|
|
101
|
+
http_client_factory: AsyncHttpClientFactory | None = None,
|
|
102
|
+
) -> QueueClientAsgiApp:
|
|
103
|
+
"""Create an ASGI queue push-callback app."""
|
|
104
|
+
configure_asgi_logger()
|
|
105
|
+
resolved_client = client or QueueClient(
|
|
106
|
+
token=token,
|
|
107
|
+
region=region,
|
|
108
|
+
base_url=base_url,
|
|
109
|
+
deployment=deployment,
|
|
110
|
+
headers=headers,
|
|
111
|
+
timeout=timeout,
|
|
112
|
+
http_client_factory=http_client_factory,
|
|
113
|
+
)
|
|
114
|
+
return QueueClientAsgiApp(resolved_client)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def _body_chunks(receive: AsgiReceive) -> AsyncIterator[bytes]:
|
|
118
|
+
while True:
|
|
119
|
+
message = await receive()
|
|
120
|
+
message_type = message.get("type")
|
|
121
|
+
if message_type == "http.disconnect":
|
|
122
|
+
raise ValueError("request body disconnected before completion")
|
|
123
|
+
if message_type != "http.request":
|
|
124
|
+
raise ValueError(f"unexpected ASGI message: {message_type!r}")
|
|
125
|
+
body = message.get("body", b"")
|
|
126
|
+
if body:
|
|
127
|
+
yield body
|
|
128
|
+
if not message.get("more_body", False):
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def _send_status(send: AsgiSend, status: int) -> None:
|
|
133
|
+
headers: list[tuple[bytes, bytes]] = []
|
|
134
|
+
if status == 405:
|
|
135
|
+
headers.append((b"allow", b"POST"))
|
|
136
|
+
await send({
|
|
137
|
+
"type": "http.response.start",
|
|
138
|
+
"status": status,
|
|
139
|
+
"headers": headers,
|
|
140
|
+
})
|
|
141
|
+
await send({"type": "http.response.body", "body": b""})
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Only add public symbols to __all__; internal helpers must stay unexported.
|
|
145
|
+
__all__ = ("QueueClientAsgiApp", "asgi_app")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, TypeGuard, TypeVar
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Awaitable, Coroutine, Iterable, Iterator
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _is_coroutine(value: Awaitable[T]) -> TypeGuard[Coroutine[Any, Any, T]]:
|
|
11
|
+
return isinstance(value, Coroutine)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def iter_coroutine(coro: Coroutine[Any, Any, T]) -> T:
|
|
15
|
+
"""Execute a non-suspending coroutine synchronously."""
|
|
16
|
+
try:
|
|
17
|
+
coro.send(None)
|
|
18
|
+
except StopIteration as exc:
|
|
19
|
+
return exc.value
|
|
20
|
+
else:
|
|
21
|
+
raise RuntimeError(f"coroutine {coro!r} did not stop after one iteration!")
|
|
22
|
+
finally:
|
|
23
|
+
coro.close()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def iter_async_iterator(iterator: AsyncIterator[T]) -> Iterator[T]:
|
|
27
|
+
"""Iterate over a non-suspending async iterator synchronously."""
|
|
28
|
+
while True:
|
|
29
|
+
try:
|
|
30
|
+
next_item = anext(iterator)
|
|
31
|
+
if not _is_coroutine(next_item):
|
|
32
|
+
raise TypeError("async iterator __anext__() must return a coroutine")
|
|
33
|
+
yield iter_coroutine(next_item)
|
|
34
|
+
except StopAsyncIteration:
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def iter_bytes_async(payload: Iterable[bytes]) -> AsyncIterator[bytes]:
|
|
39
|
+
for chunk in payload:
|
|
40
|
+
yield chunk
|