@spooled/sdk 1.2.1 → 1.4.0
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.
- package/README.md +119 -18
- package/dist/{index-CWVI9LRo.d.cts → index-DzTC8ld3.d.cts} +741 -134
- package/dist/{index-CWVI9LRo.d.ts → index-DzTC8ld3.d.ts} +741 -134
- package/dist/index.cjs +758 -304
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -151
- package/dist/index.d.ts +9 -151
- package/dist/index.js +757 -305
- package/dist/index.js.map +1 -1
- package/dist/worker/index.cjs +164 -45
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +1 -1
- package/dist/worker/index.d.ts +1 -1
- package/dist/worker/index.js +164 -45
- package/dist/worker/index.js.map +1 -1
- package/package.json +4 -3
- package/proto/spooled.proto +19 -1
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ Official Node.js/TypeScript SDK for [Spooled Cloud](https://spooled.cloud) - a m
|
|
|
21
21
|
- **Organization management** with usage tracking
|
|
22
22
|
- **Webhook management** with automatic retries
|
|
23
23
|
- **Dead Letter Queue (DLQ)** operations
|
|
24
|
+
- **Server-side job search** (status lists, text, job type, creation time), paging with `hasMore`, and **job history**
|
|
24
25
|
- **API Key management** with Redis caching (~28x faster authentication)
|
|
25
26
|
- **Billing integration** via Stripe
|
|
26
27
|
- **Automatic case conversion** between camelCase (SDK) and snake_case (API)
|
|
@@ -109,6 +110,24 @@ const { id } = await client.jobs.create({
|
|
|
109
110
|
});
|
|
110
111
|
```
|
|
111
112
|
|
|
113
|
+
Find jobs on the server and page through them (API 0.1.122+):
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
const page = await client.jobs.listPage({
|
|
117
|
+
status: ["failed", "deadletter"], // one status or several
|
|
118
|
+
q: "invoice", // id prefix, or text in the queue name or payload.job_type
|
|
119
|
+
jobType: "send-invoice", // exact payload.job_type
|
|
120
|
+
createdAfter: new Date(Date.now() - 86_400_000), // Date or RFC 3339 string
|
|
121
|
+
limit: 100,
|
|
122
|
+
});
|
|
123
|
+
console.log(page.data.length, page.hasMore); // hasMore comes from X-Has-More
|
|
124
|
+
|
|
125
|
+
// What happened to a job: claims, completions, retries, dead-lettering
|
|
126
|
+
const history = await client.jobs.history(id, { order: "desc" });
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`jobs.list()` takes the same filters and still returns a plain array.
|
|
130
|
+
|
|
112
131
|
### Workers
|
|
113
132
|
|
|
114
133
|
Process jobs with the built-in worker runtime:
|
|
@@ -136,10 +155,30 @@ await worker.start();
|
|
|
136
155
|
process.on("SIGTERM", () => worker.stop());
|
|
137
156
|
```
|
|
138
157
|
|
|
158
|
+
`stop()` drains. The worker stops claiming, then lets running handlers finish for up to
|
|
159
|
+
`shutdownTimeout` (default 30 s). A handler that returns is completed and one that throws is
|
|
160
|
+
failed, even though the stop was requested meanwhile; `ctx.signal` is not aborted during that
|
|
161
|
+
grace period. Handlers still running when it ends are aborted and failed with "Worker shutdown
|
|
162
|
+
timeout". The worker deregisters last.
|
|
163
|
+
|
|
164
|
+
If a job has `timeoutSeconds`, the worker aborts the handler (`ctx.signal`) when it runs longer,
|
|
165
|
+
emits `job:timeout`, and fails the job with "Job timeout exceeded". The error text sent with a
|
|
166
|
+
failure is always made acceptable to the API: empty becomes "Job failed", and text over 2048
|
|
167
|
+
characters is truncated.
|
|
168
|
+
|
|
169
|
+
`complete`, `fail` and both heartbeats are retried on network errors, timeouts and 5xx, because
|
|
170
|
+
the server makes them idempotent. If an ack still fails after the retries the worker emits `error`.
|
|
171
|
+
|
|
139
172
|
Give any worker that restarts a stable `workerId` (1-128 chars, `[A-Za-z0-9._-]`). Registration then
|
|
140
173
|
upserts a single row instead of minting a new UUID each start, which otherwise leaves the previous
|
|
141
174
|
registration counting against your plan's worker limit until the stale-worker reaper clears it
|
|
142
|
-
(~2 minutes).
|
|
175
|
+
(~2 minutes). `workers.get()` and `workers.list()` report `jobsProcessed` and `jobsFailed` per worker
|
|
176
|
+
(API 0.1.122+).
|
|
177
|
+
|
|
178
|
+
If you process jobs without `SpooledWorker` (`jobs.claim()` and then `jobs.complete()`,
|
|
179
|
+
`jobs.fail()` or `jobs.heartbeat()`), pass the claimed job's `leaseId` on every one of those calls.
|
|
180
|
+
The API requires it: a call without it fails with a `ValidationError` (HTTP 422), and a stale lease
|
|
181
|
+
with HTTP 409. `SpooledWorker` does this for you.
|
|
143
182
|
|
|
144
183
|
### Workflows (DAGs)
|
|
145
184
|
|
|
@@ -203,9 +242,9 @@ Manage your organization and track usage:
|
|
|
203
242
|
// Get current usage and limits
|
|
204
243
|
const usage = await client.organizations.getUsage();
|
|
205
244
|
console.log(
|
|
206
|
-
`Active jobs: ${usage.
|
|
245
|
+
`Active jobs: ${usage.usage.activeJobs.current}/${usage.usage.activeJobs.limit}`,
|
|
207
246
|
);
|
|
208
|
-
console.log(`Plan: ${usage.plan
|
|
247
|
+
console.log(`Plan: ${usage.plan}`); // e.g. "free"
|
|
209
248
|
|
|
210
249
|
// Generate a unique slug for a new organization
|
|
211
250
|
const { slug } = await client.organizations.generateSlug("My Company");
|
|
@@ -227,6 +266,14 @@ const webhook = await client.webhooks.create({
|
|
|
227
266
|
secret: "webhook_secret_key",
|
|
228
267
|
});
|
|
229
268
|
|
|
269
|
+
// Post dead-lettered jobs and workflow outcomes straight to a Slack channel
|
|
270
|
+
await client.webhooks.create({
|
|
271
|
+
name: "On-call",
|
|
272
|
+
url: "https://hooks.slack.com/services/...",
|
|
273
|
+
events: ["job.deadlettered", "workflow.failed"],
|
|
274
|
+
format: "slack", // "json" (default) | "slack" | "discord" | "teams"
|
|
275
|
+
});
|
|
276
|
+
|
|
230
277
|
// Retry a failed delivery
|
|
231
278
|
await client.webhooks.retryDelivery(webhookId, deliveryId);
|
|
232
279
|
|
|
@@ -235,6 +282,13 @@ await client.webhooks.retryDelivery(webhookId, deliveryId);
|
|
|
235
282
|
const deliveries = await client.webhooks.getDeliveries(webhookId);
|
|
236
283
|
```
|
|
237
284
|
|
|
285
|
+
API 0.1.122 added the events `job.deadlettered`, `job.retried`, `workflow.completed` and
|
|
286
|
+
`workflow.failed`, and `format`, which sends each delivery as a chat message that a Slack, Discord
|
|
287
|
+
or Teams (legacy Office 365 connector) incoming-webhook URL accepts as it is.
|
|
288
|
+
|
|
289
|
+
The inbound side has a log too: `client.ingest.listDeliveries({ status, since, queueName })` lists
|
|
290
|
+
the accepted requests to your custom webhook URL (`GET /webhooks/deliveries`, no payloads).
|
|
291
|
+
|
|
238
292
|
After 20 consecutive failed deliveries a webhook is disabled automatically (`enabled: false`,
|
|
239
293
|
`lastStatus: "auto_disabled"`) and stops receiving events until you turn it back on:
|
|
240
294
|
|
|
@@ -282,7 +336,7 @@ Manage API keys programmatically:
|
|
|
282
336
|
const apiKey = await client.apiKeys.create({
|
|
283
337
|
name: "Production Worker",
|
|
284
338
|
queues: ["emails", "notifications"],
|
|
285
|
-
rateLimit: 1000,
|
|
339
|
+
rateLimit: 1000, // optional, requests per MINUTE for this key (can only lower the plan limit)
|
|
286
340
|
});
|
|
287
341
|
|
|
288
342
|
console.log(`Save this key: ${apiKey.key}`); // Only shown once!
|
|
@@ -337,10 +391,13 @@ All operations automatically enforce tier-based limits:
|
|
|
337
391
|
|
|
338
392
|
| Tier | Active Jobs | Daily Jobs | Queues | Workers | Webhooks |
|
|
339
393
|
| -------------- | ----------- | ---------- | --------- | --------- | --------- |
|
|
340
|
-
| **Free** | 10 | 1,000 |
|
|
341
|
-
| **Starter** |
|
|
394
|
+
| **Free** | 10 | 1,000 | 2 | 1 | 1 |
|
|
395
|
+
| **Starter** | 500 | 10,000 | 10 | 5 | 5 |
|
|
396
|
+
| **Pro** | 5,000 | 100,000 | 50 | 25 | 20 |
|
|
342
397
|
| **Enterprise** | Unlimited | Unlimited | Unlimited | Unlimited | Unlimited |
|
|
343
398
|
|
|
399
|
+
(Source: the backend's `config/plans.rs`; Free has no workflows, Starter 5, Pro 25.)
|
|
400
|
+
|
|
344
401
|
**Limits are enforced on:**
|
|
345
402
|
|
|
346
403
|
- ✅ HTTP job creation (`POST /jobs`, `POST /jobs/bulk`)
|
|
@@ -375,7 +432,23 @@ try {
|
|
|
375
432
|
}
|
|
376
433
|
```
|
|
377
434
|
|
|
378
|
-
(
|
|
435
|
+
(Request-rate limiting also returns `429`, as a `RateLimitError` with the default `RATE_LIMIT_EXCEEDED` code. The plan limit is per organization in requests per second; an API key's own `rateLimit` is requests per minute.)
|
|
436
|
+
|
|
437
|
+
Since API 0.1.122, responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
|
|
438
|
+
(a Unix time in seconds) and `X-RateLimit-Scope` (`organization` or `api_key`), and a 429 adds
|
|
439
|
+
`Retry-After`. `client.getRateLimitInfo()` returns the values from the most recent response that had
|
|
440
|
+
them, and `RateLimitError.rateLimitInfo` those of a 429. The headers are optional: public routes and
|
|
441
|
+
servers without Redis do not send them.
|
|
442
|
+
|
|
443
|
+
```typescript
|
|
444
|
+
await client.jobs.getStats();
|
|
445
|
+
const rate = client.getRateLimitInfo();
|
|
446
|
+
// { limit: 200, remaining: 199, reset: Date, scope: "organization" }
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
A plan quota does not clear for hours, so `error.isQuota` is `true` for it and the SDK never retries it.
|
|
450
|
+
Rate-limit 429s are retried after `Retry-After`. No 429 counts toward the circuit breaker, so a burst
|
|
451
|
+
above the plan limit slows the client down instead of opening the breaker for every call.
|
|
379
452
|
|
|
380
453
|
## Error Handling
|
|
381
454
|
|
|
@@ -398,8 +471,9 @@ try {
|
|
|
398
471
|
} else if (error instanceof AuthorizationError) {
|
|
399
472
|
console.log(`Forbidden: ${error.message}`);
|
|
400
473
|
} else if (error instanceof RateLimitError) {
|
|
401
|
-
// 429: either a plan quota (code "QUOTA_EXCEEDED") or per-second rate limiting
|
|
402
|
-
|
|
474
|
+
// 429: either a plan quota (error.isQuota, code "QUOTA_EXCEEDED") or per-second rate limiting
|
|
475
|
+
if (!error.isQuota)
|
|
476
|
+
console.log(`Retry after ${error.getRetryAfter()} seconds`);
|
|
403
477
|
} else if (isSpooledError(error)) {
|
|
404
478
|
console.log(`API error: ${error.code} - ${error.message}`);
|
|
405
479
|
}
|
|
@@ -422,11 +496,12 @@ const grpcClient = new SpooledGrpcClient({
|
|
|
422
496
|
useTls: true,
|
|
423
497
|
});
|
|
424
498
|
|
|
425
|
-
// Register worker
|
|
499
|
+
// Register worker (a stable workerId is reused across restarts)
|
|
426
500
|
const { workerId } = await grpcClient.workers.register({
|
|
427
501
|
queueName: "emails",
|
|
428
502
|
hostname: "worker-1",
|
|
429
503
|
maxConcurrency: 10,
|
|
504
|
+
workerId: "emails-worker-1",
|
|
430
505
|
});
|
|
431
506
|
|
|
432
507
|
// Enqueue job (enforces plan limits automatically)
|
|
@@ -444,18 +519,44 @@ const { jobs } = await grpcClient.queue.dequeue({
|
|
|
444
519
|
leaseDurationSecs: 300,
|
|
445
520
|
});
|
|
446
521
|
|
|
447
|
-
// Process and
|
|
522
|
+
// Process and settle. leaseId is required: the server rejects a complete/fail/renew
|
|
523
|
+
// without the lease token that dequeue returned. fail() retries by default (until the
|
|
524
|
+
// job's maxRetries run out); pass retry: false to dead-letter on purpose.
|
|
448
525
|
for (const job of jobs) {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
526
|
+
const leaseId = job.leaseId as string;
|
|
527
|
+
try {
|
|
528
|
+
await processJob(job);
|
|
529
|
+
await grpcClient.queue.complete({
|
|
530
|
+
jobId: job.id,
|
|
531
|
+
workerId,
|
|
532
|
+
leaseId,
|
|
533
|
+
result: { success: true },
|
|
534
|
+
});
|
|
535
|
+
} catch (error) {
|
|
536
|
+
await grpcClient.queue.fail({
|
|
537
|
+
jobId: job.id,
|
|
538
|
+
workerId,
|
|
539
|
+
leaseId,
|
|
540
|
+
error: error instanceof Error ? error.message : String(error),
|
|
541
|
+
});
|
|
542
|
+
}
|
|
456
543
|
}
|
|
457
544
|
```
|
|
458
545
|
|
|
546
|
+
`fail` sends `retry: true` unless you pass `retry: false`; `false` is sent on the wire (proto field 4
|
|
547
|
+
has presence) and dead-letters the job at once, whatever its `maxRetries`.
|
|
548
|
+
|
|
549
|
+
Over gRPC `payload` and `result` are `google.protobuf.Struct`, so two things differ from REST: only a
|
|
550
|
+
JSON object travels (a job whose payload is an array, string, number or `null` arrives as an empty
|
|
551
|
+
object), and integers at or above 2^53 lose precision (send them as strings). Use REST for such jobs.
|
|
552
|
+
|
|
553
|
+
`dequeue` hands out up to `batchSize` jobs, each with a lease of `leaseDurationSecs` (5-3600 s; when
|
|
554
|
+
omitted the server default applies, 300 s on Spooled Cloud). For a job that can run longer than its
|
|
555
|
+
lease, pass a longer lease or renew it before it expires (`renewLease` with `jobId`, `workerId`,
|
|
556
|
+
`leaseId`, `extensionSecs`). A gRPC `StreamJobs` stream delivers **one job at a time**: the next
|
|
557
|
+
job is sent only after the previous one on that stream is completed, failed or released, so open
|
|
558
|
+
one stream per job slot you want to run in parallel.
|
|
559
|
+
|
|
459
560
|
**gRPC Features:**
|
|
460
561
|
|
|
461
562
|
- **~28x faster** than HTTP (with Redis cache: ~50ms vs 1400ms)
|