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,681 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vercel-queue
|
|
3
|
+
Version: 0.7.0
|
|
4
|
+
Summary: Vercel Queue client for Python
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: anyio>=4.0.0
|
|
9
|
+
Requires-Dist: httpx[http2]>=0.27.0
|
|
10
|
+
Requires-Dist: python-multipart>=0.0.20
|
|
11
|
+
Requires-Dist: typing-extensions>=4.0.0
|
|
12
|
+
Requires-Dist: vercel-headers>=0.7.0
|
|
13
|
+
Requires-Dist: vercel-oidc>=0.7.0
|
|
14
|
+
Provides-Extra: devserver
|
|
15
|
+
Requires-Dist: uvicorn; extra == 'devserver'
|
|
16
|
+
Provides-Extra: trio
|
|
17
|
+
Requires-Dist: anyio[trio]>=4.0.0; extra == 'trio'
|
|
18
|
+
Provides-Extra: typed
|
|
19
|
+
Requires-Dist: pydantic>=2.7.0; extra == 'typed'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# Vercel Queues
|
|
23
|
+
|
|
24
|
+
A Python client library for interacting with the Vercel Queues API,
|
|
25
|
+
designed for seamless integration with Vercel deployments.
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- **Simple API**: `send`, `subscribe`, and `asgi_app` cover standard workflows.
|
|
30
|
+
- **Automatic Triggering on Vercel**: Vercel invokes your function when messages are ready.
|
|
31
|
+
- **Works Anywhere**: `send`, decorated subscribers, and manual `poll` loops work on Vercel, self-hosted workers, and locally.
|
|
32
|
+
- **Sync and Async Clients**: Prefer async for applications, use sync for scripts and blocking workers.
|
|
33
|
+
- **Type Safety**: `Topic[T]`, typed messages, and optional Pydantic validation.
|
|
34
|
+
- **Customizable Serialization**: Built-in JSON, text, binary, and streaming transports.
|
|
35
|
+
- **Local Development Support**: Embedded queue helpers for tests and development.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv add vercel-queue
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For Pydantic-backed typed payloads, add the optional `typed` extra:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv add "vercel-queue[typed]"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quick Start
|
|
50
|
+
|
|
51
|
+
**1. Link your Vercel project and pull credentials:**
|
|
52
|
+
|
|
53
|
+
The SDK authenticates via OIDC. Link your project if you haven't already, then
|
|
54
|
+
pull to get fresh tokens:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npm i -g vercel
|
|
58
|
+
vc link # if you haven't already
|
|
59
|
+
vc env pull
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**2. Send a message anywhere in your app:**
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from vercel.queue import send
|
|
66
|
+
|
|
67
|
+
message_id = await send("my-topic", {"message": "Hello world"})
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**3. Handle incoming messages with an API route function:**
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
# api/queue.py
|
|
74
|
+
from vercel.queue import asgi_app, subscribe
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@subscribe(topic="my-topic", consumer_group="api/queue.py")
|
|
78
|
+
async def process_message(message):
|
|
79
|
+
print("Processing:", message)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# An ASGI app instance that converts incoming message callbacks
|
|
83
|
+
# sent by Vercel Queues and routes them to handlers.
|
|
84
|
+
app = asgi_app()
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**4. Configure `vercel.json`:**
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"functions": {
|
|
92
|
+
"api/queue.py": {
|
|
93
|
+
"experimentalTriggers": [{ "type": "queue/v2beta", "topic": "my-topic" }]
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**5. Deploy:**
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
vc deploy
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Publishing Messages
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from datetime import timedelta
|
|
109
|
+
from vercel.queue import send
|
|
110
|
+
|
|
111
|
+
# Simple send
|
|
112
|
+
message_id = await send("my-topic", {"message": "Hello world"})
|
|
113
|
+
|
|
114
|
+
# With options
|
|
115
|
+
message_id = await send(
|
|
116
|
+
"my-topic",
|
|
117
|
+
{"message": "Hello world"},
|
|
118
|
+
idempotency_key="unique-key", # Prevent duplicate messages
|
|
119
|
+
retention=timedelta(hours=1),
|
|
120
|
+
delay=timedelta(minutes=1), # Delay delivery by 1 minute
|
|
121
|
+
)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Example usage in a FastAPI route:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from fastapi import FastAPI
|
|
128
|
+
|
|
129
|
+
from vercel.queue import send
|
|
130
|
+
|
|
131
|
+
app = FastAPI()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.post("/orders")
|
|
135
|
+
async def create_order(order: dict[str, str]) -> dict[str, str | None]:
|
|
136
|
+
message_id = await send("my-topic", {"message": "Hello world"})
|
|
137
|
+
return {"message_id": message_id}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`send` returns a message ID, or `None` when the server accepted the message for delivery,
|
|
141
|
+
but could not process it fully yet. Deferred messages are still delivered.
|
|
142
|
+
|
|
143
|
+
## Receiving and Handling Messages
|
|
144
|
+
|
|
145
|
+
Decorated subscribers are the recommended way to handle messages. Register
|
|
146
|
+
subscribers with the `@subscribe` decorator:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
# handle_orders.py
|
|
150
|
+
from vercel.queue import subscribe
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@subscribe(topic="orders")
|
|
154
|
+
async def fulfill_order(order):
|
|
155
|
+
await process_order(order)
|
|
156
|
+
# Raising an error will automatically retry the message.
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Decorating a function with `@subscribe` only declares it as a handler for a
|
|
160
|
+
particular topic. You also need a way to accept and route messages to handlers.
|
|
161
|
+
There are two primary ways: deploying handlers as Vercel Functions in push mode
|
|
162
|
+
and running polling loops on other infrastructure.
|
|
163
|
+
|
|
164
|
+
### Auto-scaled push-mode on Vercel
|
|
165
|
+
|
|
166
|
+
The recommended way of deploying queue subscribers is to deploy them as Vercel Functions
|
|
167
|
+
|
|
168
|
+
**Vercel Function (plain `/api` directory):**
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
# api/handle_orders.py
|
|
172
|
+
from vercel.queue import asgi_app, subscribe
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@subscribe(topic="orders", consumer_group="api/handle_orders.py")
|
|
176
|
+
async def handle_order(message):
|
|
177
|
+
print("Processing:", message)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# An ASGI app instance that converts incoming message callbacks
|
|
181
|
+
# sent by Vercel Queues and routes them to handlers.
|
|
182
|
+
app = asgi_app()
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**vercel.json**:
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
{
|
|
189
|
+
"functions": {
|
|
190
|
+
"api/queue/orders.py": {
|
|
191
|
+
"experimentalTriggers": [
|
|
192
|
+
{
|
|
193
|
+
"type": "queue/v2beta",
|
|
194
|
+
"topic": "orders",
|
|
195
|
+
"retryAfterSeconds": 60,
|
|
196
|
+
"initialDelaySeconds": 0
|
|
197
|
+
}
|
|
198
|
+
]
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Automatic Polling Loop
|
|
205
|
+
|
|
206
|
+
Queue message handlers can also be invoked by polling a topic manually. `vercel.queue` provides a
|
|
207
|
+
convenience API that starts an infinite polling loop for messages matching a given subscriber:
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
# subscriber_poll.py
|
|
211
|
+
import asyncio
|
|
212
|
+
from vercel.queue import poll_and_handle, subscribe
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@subscribe(topic="orders")
|
|
216
|
+
async def fulfill_order(order):
|
|
217
|
+
print("Processing Order:", order)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def main():
|
|
221
|
+
poller = asyncio.create_task(poll_and_handle(fulfill_order, interval=1))
|
|
222
|
+
# do work and cancel poller on a condition, such as a signal
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
asyncio.run(main())
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
See [subscriber_poll.py example](./examples/subscriber_poll.py) for a complete
|
|
229
|
+
example.
|
|
230
|
+
|
|
231
|
+
Naturally, a synchronous polling loop helper is also available:
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
# subscriber_poll_sync.py
|
|
235
|
+
from vercel.queue.sync import poll_and_handle, subscribe
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@subscribe(topic="orders")
|
|
239
|
+
def fulfill_order(order):
|
|
240
|
+
print("Processing Order:", order)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def main():
|
|
244
|
+
poller = poll_and_handle(fulfill_order, interval=1)
|
|
245
|
+
# ...
|
|
246
|
+
poller.cancel()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
main()
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Synchronous polling loops use threads instead of async tasks.
|
|
253
|
+
|
|
254
|
+
### Manual Polling
|
|
255
|
+
|
|
256
|
+
It is also possible to poll a topic and receive messages directly:
|
|
257
|
+
|
|
258
|
+
```python
|
|
259
|
+
# poll_loop.py
|
|
260
|
+
from vercel.queue import poll
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def main():
|
|
264
|
+
async for delivery in poll(
|
|
265
|
+
topic="orders",
|
|
266
|
+
consumer_group="fulfillment",
|
|
267
|
+
limit=10,
|
|
268
|
+
):
|
|
269
|
+
async with delivery as message:
|
|
270
|
+
await process_order(message.payload)
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
`poll()` polls the topic once for up to `limit` message deliverires which are then yielded by the
|
|
274
|
+
iterator. Use the yielded `delivery` as a context manager to obtain the message envelope which
|
|
275
|
+
contains the `payload` and `metadata` properties. Note that `limit` means _up-to_ and it is
|
|
276
|
+
possible for `poll()` to return an empty iterator. In other words, `poll()` does not block until
|
|
277
|
+
new messages are available and it is usually necessary to build a polling loop around it.
|
|
278
|
+
|
|
279
|
+
### Region Considerations when Polling
|
|
280
|
+
|
|
281
|
+
Messages can only be received from the region they were sent to. When polling, use a fixed region
|
|
282
|
+
for both sending and receiving, such as `"iad1"`. Avoid using a changing runtime region for manual
|
|
283
|
+
polling, because it can distribute messages across regions unpredictably.
|
|
284
|
+
|
|
285
|
+
## Retry and Backoff
|
|
286
|
+
|
|
287
|
+
When a topic handler raises, the message is not acknowledged and becomes available for redelivery
|
|
288
|
+
after the `retryAfterSeconds` interval configured in `vercel.json`. Retries continue until the
|
|
289
|
+
handler succeeds or the message expires.
|
|
290
|
+
|
|
291
|
+
For finer control over retry timing, raise `RetryAfter` from a subscriber:
|
|
292
|
+
|
|
293
|
+
```python
|
|
294
|
+
from vercel.queue import RetryAfter, subscribe
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@subscribe(topic="orders")
|
|
298
|
+
async def fulfill_order(order: dict[str, str]) -> None:
|
|
299
|
+
try:
|
|
300
|
+
await process_order(order)
|
|
301
|
+
except RateLimitError as exc:
|
|
302
|
+
raise RetryAfter(60) from exc
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Use `message.metadata.delivery_count` for exponential backoff:
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
from vercel.queue import Message, RetryAfter, subscribe
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@subscribe(topic="orders")
|
|
312
|
+
async def fulfill_order(message: Message[dict[str, str]]) -> None:
|
|
313
|
+
try:
|
|
314
|
+
await process_order(message.payload)
|
|
315
|
+
except TemporaryError as exc:
|
|
316
|
+
delay = min(300, 2**message.metadata.delivery_count * 5)
|
|
317
|
+
raise RetryAfter(delay) from exc
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
## Custom Client Configuration
|
|
321
|
+
|
|
322
|
+
For most use cases, the top-level `send`, `poll_and_handle`, and `poll` are all you need. For
|
|
323
|
+
advanced configuration such as explicit authentication, custom headers, deployment pinning, or
|
|
324
|
+
custom queue service URLs, create a `QueueClient` explicitly:
|
|
325
|
+
|
|
326
|
+
```python
|
|
327
|
+
# explicit_client.py
|
|
328
|
+
from vercel.queue import ALL_DEPLOYMENTS, QueueClient
|
|
329
|
+
|
|
330
|
+
queue = QueueClient(
|
|
331
|
+
region="iad1", # Required unless VERCEL_REGION is set
|
|
332
|
+
token="my-token", # Auth token; detected from the environment by default
|
|
333
|
+
headers={"X-Custom": "header"},
|
|
334
|
+
deployment=ALL_DEPLOYMENTS, # Receive messages from all deployments when polling
|
|
335
|
+
timeout=10, # default timeout for API operations
|
|
336
|
+
http_client_factory=httpx2.AsyncClient, # any httpx-compatible HTTP client
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
await queue.send("my-topic", {"message": "Hello world"})
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
By default queue clients send requests to `https://<region>.vercel-queue.com/`. A custom endpoint
|
|
343
|
+
can be configured by passing a `base_url` keyword argument to the client constructor. The value
|
|
344
|
+
can be a fixed string, a `format()` template containing a `{region}` placeholder, or a callable
|
|
345
|
+
taking region name as a string and returning a formatted URL:
|
|
346
|
+
|
|
347
|
+
```python
|
|
348
|
+
from vercel.queue import QueueClient
|
|
349
|
+
|
|
350
|
+
# Custom domain with a base path.
|
|
351
|
+
queue = QueueClient(base_url="https://proxy.example/queues/{region}")
|
|
352
|
+
|
|
353
|
+
# Callable resolver.
|
|
354
|
+
queue = QueueClient(base_url=lambda region: f"https://{region}.queue.internal")
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
## Type-safe Message Passing and Streaming
|
|
358
|
+
|
|
359
|
+
By default, messages passed to `send()` and received by `@subscribe` handlers and `poll()` are
|
|
360
|
+
transmitted as JSON, so values must be JSON-serializable by Python. This is usually fine for
|
|
361
|
+
unstructured data, such as dictionaries and lists containing simple data. For more complex types,
|
|
362
|
+
such as `dataclasses` or Pydantic models, simply annotate the first argument of the handler
|
|
363
|
+
function:
|
|
364
|
+
|
|
365
|
+
```python
|
|
366
|
+
from dataclasses import dataclass
|
|
367
|
+
from vercel.queue import send, subscribe
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@dataclass
|
|
371
|
+
class Email:
|
|
372
|
+
to: str
|
|
373
|
+
subject: str
|
|
374
|
+
body: str
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@subscribe(topic="emails")
|
|
378
|
+
async def receive_email(email: Email) -> None:
|
|
379
|
+
print(f"Received email to {email.to}: {email.subject}")
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Note that type-safe message handling requires `pydantic>=2.0` to be available. The simplest way
|
|
383
|
+
to ensure the correct version of Pydantic is to install the `vercel-queue` package with the
|
|
384
|
+
`[typed]` feature: `uv add vercel-queue[typed]`.
|
|
385
|
+
|
|
386
|
+
To send types that require non-trivial serialization, pass the topic name not as a plain string,
|
|
387
|
+
but as a type-specialized `Topic` instance:
|
|
388
|
+
|
|
389
|
+
```python
|
|
390
|
+
# custom_transport.py
|
|
391
|
+
from vercel.queue import Topic, send
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@dataclass
|
|
395
|
+
class Email:
|
|
396
|
+
to: str
|
|
397
|
+
subject: str
|
|
398
|
+
body: str
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
emails_topic = Topic[Email]("emails")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
async def send_email(to, subject, body):
|
|
405
|
+
await send(emails_topic, Email(to, subject, body))
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# Type-specialized topics can also be used with @subscribe, in which case the
|
|
409
|
+
# type annotation on the handler argument must match the type of the topic
|
|
410
|
+
@subscribe(topic=emails_topic)
|
|
411
|
+
async def receive_email(email: Email) -> None:
|
|
412
|
+
print(f"Received email to {email.to}: {email.subject}")
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Explicit type annotations can also be used to enable payload streaming for large messages:
|
|
416
|
+
|
|
417
|
+
```python
|
|
418
|
+
# streaming.py
|
|
419
|
+
from collections.abc import AsyncIterable, AsyncIterator
|
|
420
|
+
|
|
421
|
+
from vercel.queue import ByteStreamTransport, QueueClient, Topic, subscribe
|
|
422
|
+
|
|
423
|
+
large_file = Topic[AsyncIterable[bytes]]("large-file")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
async def file_chunks():
|
|
427
|
+
with open("large.bin", "rb") as file:
|
|
428
|
+
while chunk := file.read(1024 * 1024):
|
|
429
|
+
yield chunk
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
async def send_file():
|
|
433
|
+
await queue.send(large_file, file_chunks())
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@subscribe(topic=large_file, consumer_group="archive")
|
|
437
|
+
async def archive_file(chunks: AsyncIterable[bytes]) -> None:
|
|
438
|
+
async for chunk in chunks:
|
|
439
|
+
await write_chunk(chunk)
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
Message transport is automatically determined from the handler argument type annotation
|
|
443
|
+
or `Topic` type specialization according to the following table:
|
|
444
|
+
|
|
445
|
+
| Topic payload type | Default transport | Message format |
|
|
446
|
+
| ------------------------------------------------ | ----------------------- | ---------------------------- |
|
|
447
|
+
| JSON-compatible values, `dict[...]`, `list[...]` | `RawJsonTransport[Any]` | JSON |
|
|
448
|
+
| Pydantic models and other structured annotations | `TypedJsonTransport[T]` | JSON with receive validation |
|
|
449
|
+
| `bytes` | `ByteBufferTransport` | Buffered binary |
|
|
450
|
+
| `str` | `TextBufferTransport` | Buffered UTF-8 text |
|
|
451
|
+
| `Iterable[bytes]` or `AsyncIterable[bytes]` | `ByteStreamTransport` | Streaming binary |
|
|
452
|
+
| `Iterable[str]` or `AsyncIterable[str]` | `TextStreamTransport` | Streaming UTF-8 text |
|
|
453
|
+
|
|
454
|
+
Transport can also be set explicitly on the topic when the topic is unstructured or
|
|
455
|
+
when custom serialization is needed:
|
|
456
|
+
|
|
457
|
+
```python
|
|
458
|
+
# custom_transport.py
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@dataclass
|
|
462
|
+
class Invoice:
|
|
463
|
+
invoice_id: str
|
|
464
|
+
customer_id: str
|
|
465
|
+
total_cents: int
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class InvoiceFormTransport:
|
|
469
|
+
content_type = "application/x-www-form-urlencoded"
|
|
470
|
+
|
|
471
|
+
def serialize(self, value: Invoice) -> bytes:
|
|
472
|
+
return urlencode({
|
|
473
|
+
"invoice_id": value.invoice_id,
|
|
474
|
+
"customer_id": value.customer_id,
|
|
475
|
+
"total_cents": str(value.total_cents),
|
|
476
|
+
}).encode("utf-8")
|
|
477
|
+
|
|
478
|
+
async def deserialize(
|
|
479
|
+
self,
|
|
480
|
+
payload: AsyncIterator[bytes],
|
|
481
|
+
*,
|
|
482
|
+
content_type: str,
|
|
483
|
+
) -> Invoice:
|
|
484
|
+
body = bytearray()
|
|
485
|
+
async for chunk in payload:
|
|
486
|
+
body.extend(chunk)
|
|
487
|
+
|
|
488
|
+
parsed = parse_qs(body.decode("utf-8"), strict_parsing=True)
|
|
489
|
+
return Invoice(
|
|
490
|
+
invoice_id=_single(parsed, "invoice_id"),
|
|
491
|
+
customer_id=_single(parsed, "customer_id"),
|
|
492
|
+
total_cents=int(_single(parsed, "total_cents")),
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
invoice_topic = Topic[Invoice](
|
|
497
|
+
"invoices",
|
|
498
|
+
transport=InvoiceFormTransport(),
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@subscribe(topic=invoice_topic)
|
|
503
|
+
async def handle_invoice(invoice: Invoice) -> None: ...
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
## Error Handling
|
|
507
|
+
|
|
508
|
+
```python
|
|
509
|
+
from vercel.queue import (
|
|
510
|
+
BadRequestError,
|
|
511
|
+
DuplicateIdempotencyKeyError,
|
|
512
|
+
ForbiddenError,
|
|
513
|
+
InternalServerError,
|
|
514
|
+
UnauthorizedError,
|
|
515
|
+
send,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
await send("my-topic", payload)
|
|
520
|
+
except UnauthorizedError:
|
|
521
|
+
print("Invalid token - refresh authentication")
|
|
522
|
+
except ForbiddenError:
|
|
523
|
+
print("Environment mismatch - check configuration")
|
|
524
|
+
except BadRequestError as exc:
|
|
525
|
+
print("Invalid parameters:", exc)
|
|
526
|
+
except DuplicateIdempotencyKeyError as exc:
|
|
527
|
+
print("Duplicate idempotency key:", exc)
|
|
528
|
+
except InternalServerError:
|
|
529
|
+
print("Server error - retry with backoff")
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
All error types:
|
|
533
|
+
|
|
534
|
+
| Error | Description |
|
|
535
|
+
| ------------------------------------ | --------------------------------------------- |
|
|
536
|
+
| `BadRequestError` | Invalid request parameters |
|
|
537
|
+
| `UnauthorizedError` | Authentication failed |
|
|
538
|
+
| `ForbiddenError` | Access denied or environment mismatch |
|
|
539
|
+
| `DuplicateIdempotencyKeyError` | Idempotency key already used |
|
|
540
|
+
| `ConsumerDiscoveryError` | Could not reach consumer deployment |
|
|
541
|
+
| `ConsumerRegistryNotConfiguredError` | Project is not configured for queues |
|
|
542
|
+
| `DeploymentResolutionError` | Deployment ID could not be resolved |
|
|
543
|
+
| `DuplicateSubscriptionError` | Local subscriber registration overlaps |
|
|
544
|
+
| `InternalServerError` | Unexpected server error |
|
|
545
|
+
| `InvalidLimitError` | Batch limit outside valid range |
|
|
546
|
+
| `MessageAlreadyProcessedError` | Message already successfully processed |
|
|
547
|
+
| `MessageCorruptedError` | Message data could not be parsed |
|
|
548
|
+
| `MessageLockedError` | Message is being processed elsewhere |
|
|
549
|
+
| `MessageNotFoundError` | Message does not exist or expired |
|
|
550
|
+
| `MessageUnavailableError` | Message exists but cannot be claimed |
|
|
551
|
+
| `PayloadValidationError` | Payload validation failed |
|
|
552
|
+
| `ProtocolError` | Queue service returned malformed metadata |
|
|
553
|
+
| `ServiceError` | Unexpected queue response |
|
|
554
|
+
| `SubscriptionError` | Subscriber signature or configuration invalid |
|
|
555
|
+
| `ThrottledError` | Queue service throttled the request |
|
|
556
|
+
| `TokenResolutionError` | OIDC token could not be resolved |
|
|
557
|
+
| `UnhandledMessageError` | No subscriber matched an incoming delivery |
|
|
558
|
+
|
|
559
|
+
## Environment Variables
|
|
560
|
+
|
|
561
|
+
| Variable | Description | Default |
|
|
562
|
+
| ----------------------- | ---------------------------------------------- | ------- |
|
|
563
|
+
| `VERCEL_REGION` | Current region, auto-set by Vercel | - |
|
|
564
|
+
| `VERCEL_QUEUE_BASE_URL` | Fixed base URL or `{region}` template override | - |
|
|
565
|
+
| `VERCEL_QUEUE_DEBUG` | Enable debug logging with `1` or `true` | - |
|
|
566
|
+
| `VERCEL_QUEUE_TOKEN` | Queue bearer token override | - |
|
|
567
|
+
| `VERCEL_DEPLOYMENT_ID` | Deployment ID, auto-set by Vercel | - |
|
|
568
|
+
|
|
569
|
+
## Service Limits & Constraints
|
|
570
|
+
|
|
571
|
+
### Throughput & Storage
|
|
572
|
+
|
|
573
|
+
| Limit | Value | Notes |
|
|
574
|
+
| --------------------------- | --------------------- | ----------------------------------- |
|
|
575
|
+
| Message throughput | 10,000+ msg/sec/topic | Scales horizontally |
|
|
576
|
+
| Payload size | 100 MB | Smaller messages have lower latency |
|
|
577
|
+
| Number of topics | Unlimited | No hard limit |
|
|
578
|
+
| Consumer groups per message | ~4,000 | Per-message limit |
|
|
579
|
+
| Messages per queue | Unlimited | No hard limit |
|
|
580
|
+
|
|
581
|
+
### Parameter Constraints
|
|
582
|
+
|
|
583
|
+
#### Publishing Messages
|
|
584
|
+
|
|
585
|
+
| Parameter | Default | Min | Max | Notes |
|
|
586
|
+
| ----------------- | ------------ | --- | ------------ | ----------------------------------- |
|
|
587
|
+
| `retention` | 86,400 (24h) | 60 | 604,800 (7d) | Message TTL |
|
|
588
|
+
| `delay` | 0 | 0 | 604,800 (7d) | Cannot exceed retention |
|
|
589
|
+
| `idempotency_key` | - | - | - | Dedup window: `min(retention, 24h)` |
|
|
590
|
+
|
|
591
|
+
#### Receiving Messages
|
|
592
|
+
|
|
593
|
+
| Parameter | Default | Min | Max | Notes |
|
|
594
|
+
| ---------------- | ------- | --- | ----- | ------------------------------- |
|
|
595
|
+
| `lease_duration` | 300 | 30 | 3,600 | Lock duration during processing |
|
|
596
|
+
| `limit` | 1 | 1 | 10 | Messages per request |
|
|
597
|
+
|
|
598
|
+
### Identifier Formats
|
|
599
|
+
|
|
600
|
+
| Identifier | Input | Stored queue name |
|
|
601
|
+
| -------------- | -------------------- | ---------------------------- |
|
|
602
|
+
| Topic name | `[A-Za-z0-9_-]+` | Same as input |
|
|
603
|
+
| Consumer group | Any non-empty string | `sanitize_name(...)` |
|
|
604
|
+
| Message ID | Opaque string | `0-1`, `3-7K9mNpQrS` |
|
|
605
|
+
| Receipt handle | Opaque string | Used for ack and lease calls |
|
|
606
|
+
|
|
607
|
+
Use `sanitize_name` to convert arbitrary non-empty names to `SanitizedName`
|
|
608
|
+
markers. Plain strings are reversibly escaped, including underscores. Use
|
|
609
|
+
`SanitizedName` only when passing a queue-safe name that has already been
|
|
610
|
+
sanitized and must not be escaped again.
|
|
611
|
+
|
|
612
|
+
## Wildcard Topics
|
|
613
|
+
|
|
614
|
+
It is possible to subscribe to multiple topics by using a wildcard (`*`) at the
|
|
615
|
+
end of the topic name. Bare wildcards are also allowed and act as catch-all
|
|
616
|
+
handlers.
|
|
617
|
+
|
|
618
|
+
```python
|
|
619
|
+
# wildcard_topic.py
|
|
620
|
+
|
|
621
|
+
from vercel.queue import subscribe
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
@subscribe(topic="user-*")
|
|
625
|
+
async def handle_user_event(event: dict[str, str]) -> None:
|
|
626
|
+
await process_user_event(event)
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
Wildcard topics are not supported by `poll()` and if a wildcard handler is passed to
|
|
630
|
+
`poll_and_handle()`, a non-wildcard list of topics must also be specified to disambiguate
|
|
631
|
+
polling.
|
|
632
|
+
|
|
633
|
+
## Local Development
|
|
634
|
+
|
|
635
|
+
For local tests and integration development, use the embedded queue service or
|
|
636
|
+
pytest plugin. It exercises the same client send, subscriber callback, lease renewal,
|
|
637
|
+
and acknowledgement paths without requiring a deployed Vercel Function.
|
|
638
|
+
|
|
639
|
+
```python
|
|
640
|
+
import anyio
|
|
641
|
+
|
|
642
|
+
from vercel.queue import subscribe
|
|
643
|
+
from vercel.queue.embedded import embedded_queue_service
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
async def wait_until(predicate) -> None:
|
|
647
|
+
while not predicate():
|
|
648
|
+
await anyio.sleep(0.01)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
async def test_queue() -> None:
|
|
652
|
+
seen: list[str] = []
|
|
653
|
+
|
|
654
|
+
@subscribe(topic="my-topic")
|
|
655
|
+
async def handler(message: dict[str, str]) -> None:
|
|
656
|
+
seen.append(message["message"])
|
|
657
|
+
|
|
658
|
+
async with embedded_queue_service() as service:
|
|
659
|
+
client = service.get_async_client()
|
|
660
|
+
message_id = await client.send("my-topic", {"message": "Hello world"})
|
|
661
|
+
assert message_id is not None
|
|
662
|
+
service.dispatcher.wake()
|
|
663
|
+
await wait_until(lambda: service.server.state.by_id[message_id].acknowledged)
|
|
664
|
+
|
|
665
|
+
assert seen == ["Hello world"]
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
Standalone HTTP dev server support is available through the `devserver` extra:
|
|
669
|
+
|
|
670
|
+
```bash
|
|
671
|
+
python -m vercel.queue.devserver --host 127.0.0.1
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
Install it with `vercel-queue[devserver]`. The command prints a JSON `baseUrl`
|
|
675
|
+
for the local queue API, including the random available port selected when
|
|
676
|
+
`--port` is omitted. Pass `--port 8000` to bind a fixed port. This is useful for
|
|
677
|
+
cross-process or cross-runtime local integration.
|
|
678
|
+
|
|
679
|
+
## License
|
|
680
|
+
|
|
681
|
+
MIT
|