ezthrottle 1.0.0 → 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 CHANGED
@@ -1,28 +1,1104 @@
1
1
  # EZThrottle Node.js SDK
2
2
 
3
- The World's First API Aqueduct.
3
+ The API Dam for rate-limited services. Queue and execute HTTP requests with smart retry logic, multi-region racing, and webhook delivery.
4
+
5
+ ## Get Your API Key
6
+
7
+ 👉 **[Get started at ezthrottle.network](https://www.ezthrottle.network/)**
8
+
9
+ **Pay for delivery through outages and rate limiting. Unlimited free concurrency.**
10
+
11
+ No need to manage Lambda functions, SQS queues, DynamoDB, or complex retry logic. EZThrottle handles webhook fanout, distributed queuing, and multi-region orchestration for you. Just grab an API key and start shipping reliable API calls.
12
+
13
+ ### The End of Serverless Infrastructure
14
+
15
+ **RIP OPS. Hello serverless without maintenance.**
16
+
17
+ The era of managing serverless infrastructure is over. No more Lambda functions to deploy, SQS queues to configure, DynamoDB tables to provision, or CloudWatch alarms to tune. EZThrottle replaces your entire background job infrastructure with a single API call. Just code your business logic—we handle the rest.
18
+
19
+ ### Speed & Reliability Through Multi-Region Racing
20
+
21
+ Execute requests across multiple geographic regions simultaneously (IAD, LAX, ORD, etc.). **The fastest region wins**—delivering sub-second response times. When a region experiences issues, requests automatically route to healthy regions with zero configuration. Geographic distribution + intelligent routing = blazing-fast reliable delivery, every time.
4
22
 
5
23
  ## Installation
6
24
 
7
25
  ```bash
8
- npm install @tracktags/ezthrottle
26
+ npm install ezthrottle
9
27
  ```
10
28
 
11
29
  ## Quick Start
12
30
 
13
31
  ```javascript
14
- const { EZThrottle } = require('@tracktags/ezthrottle');
32
+ const { EZThrottle, Step, StepType } = require('ezthrottle');
33
+
34
+ const client = new EZThrottle({ apiKey: 'your_api_key' });
35
+
36
+ // Simple job submission
37
+ const result = await new Step(client)
38
+ .url('https://api.example.com/endpoint')
39
+ .method('POST')
40
+ .type(StepType.PERFORMANCE)
41
+ .webhooks([{ url: 'https://your-app.com/webhook' }])
42
+ .execute();
43
+
44
+ console.log(`Job ID: ${result.job_id}`);
45
+ ```
46
+
47
+ ---
48
+
49
+ # Pricing
50
+
51
+ ## Free Tier - 1 Million Requests/Month Forever
52
+
53
+ **No credit card. No limits. All features included.**
54
+
55
+ - 1,000,000 requests per month FREE
56
+ - Multi-region racing, webhook fanout, retry logic - everything
57
+ - ~30,000 requests/day (covers most production apps)
58
+ - Perfect for indie devs, startups, side projects
59
+
60
+ ## Early Adopter Pricing (Subject to Change)
61
+
62
+ | Tier | Included Requests | Monthly Price | Overage (per 100k) | Hard Cap |
63
+ |------|------------------|---------------|-------------------|----------|
64
+ | **Free** | 1M requests/month | $0 | N/A | 1M (upgrade to continue) |
65
+ | **Indie** | 2M requests/month | $50 | $50/100k | 5M (upgrade to continue) |
66
+ | **Growth** | 5M requests/month | $200 | $40/100k | 10M (upgrade to continue) |
67
+ | **Pro** | 10M requests/month | $500 | $25/100k | 25M (upgrade to continue) |
68
+
69
+ **Hard caps protect you from surprise bills.** When you hit your tier's cap, requests pause until you upgrade or the month resets.
70
+
71
+ **Overage pricing:** Pay only for what you use beyond your included requests, up to your tier's hard cap.
72
+
73
+ **Example:** Indie tier uses 3M requests = $50 (base) + $50 (1M overage) = $100 total
74
+
75
+ ## Smart Upgrade Incentives
76
+
77
+ **The math makes upgrading obvious:**
78
+
79
+ **Scenario: Using 8M requests/month**
80
+
81
+ | Option | Calculation | Total Cost |
82
+ |--------|-------------|------------|
83
+ | Stay on Indie (hit cap) | Service stops at 5M | ❌ Lost revenue |
84
+ | Pay Indie overages | $50 + ($50 × 30) = $50 + $1,500 | ❌ $1,550/month |
85
+ | Upgrade to Growth | $200 base + ($40 × 30) = $200 + $1,200 | ⚠️ $1,400/month |
86
+ | Upgrade to Pro | $500 base (includes 10M) | ✅ $500/month |
87
+
88
+ **Upgrading to Pro saves you $900-1,050/month** vs paying overages.
89
+
90
+ **The tiers are designed so you WANT to upgrade** - overage pricing is intentionally expensive to make the next tier a no-brainer.
91
+
92
+ **Need 25M+ requests/month, no caps, or custom SLAs?**
93
+ 👉 **[Contact us for enterprise pricing](https://www.ezthrottle.network/contact)**
94
+
95
+ ## Early Adopter Benefits
96
+
97
+ **Lock in these rates by signing up now.** Pricing subject to change for new customers. Early adopters keep their tier pricing even as we adjust rates.
98
+
99
+ **Questions?**
100
+ 👉 **[Pricing FAQ](https://www.ezthrottle.network/pricing)** | **[Contact sales](https://www.ezthrottle.network/contact)**
101
+
102
+ **Ready to stop debugging Lambda at 3am?**
103
+ 👉 **[Start free with 1M requests/month](https://www.ezthrottle.network/)**
104
+
105
+ ---
106
+
107
+ # Why This Pricing Makes Sense
108
+
109
+ ## What's a Good Night's Sleep Worth?
110
+
111
+ **3am PagerDuty alert:** "Stripe API down. Retry storm taking down prod. Revenue stopped."
112
+
113
+ You wake up. Laptop. VPN. SSH into servers. Lambda logs scrolling. DynamoDB throttling. SQS backlog exploding. IAM policies denying for no reason. Concurrent execution limits hit. CloudWatch costs spiking.
114
+
115
+ You spend 2 hours debugging. Fix the immediate issue. Write a post-mortem. Promise to "build better retry logic."
116
+
117
+ **Three months later, same alert. Different API.**
118
+
119
+ ---
120
+
121
+ ## The AWS Nightmare Nobody Talks About
122
+
123
+ **Building retry infrastructure on AWS means:**
124
+
125
+ **Lambda Hell:**
126
+ - Concurrent execution limits (1000 by default, need to request increases)
127
+ - Cold starts killing performance (500ms+ latency spikes)
128
+ - IAM policies that randomly deny for no fucking reason
129
+ - CloudWatch logs costing more than the Lambdas themselves
130
+ - Debugging distributed traces across 47 Lambda invocations
131
+
132
+ **SQS Madness:**
133
+ - Dead letter queues filling up
134
+ - Visibility timeout confusion (did it process? who knows!)
135
+ - FIFO vs Standard (wrong choice = data loss)
136
+ - Poison messages breaking your workers
137
+ - No built-in retry logic for 429/500 errors
138
+
139
+ **DynamoDB Pain:**
140
+ - Provisioned throughput math (always wrong)
141
+ - Hot partition keys throttling randomly
142
+ - GSI limits (20 max, need to plan carefully)
143
+ - Point-in-time recovery costing $$$
144
+ - Read/write capacity units (what even are these?)
145
+
146
+ **The Real Kicker:**
147
+ - **AWS has no built-in tool for queueing 429 and 500 errors at scale**
148
+ - You have to build it yourself
149
+ - With Lambda + SQS + DynamoDB + Step Functions + EventBridge
150
+ - And debug the whole mess when it breaks at 3am
151
+
152
+ ---
153
+
154
+ ## Why AWS Can't Do This (And EZThrottle Can)
155
+
156
+ **Performance:**
157
+ - **EZThrottle core:** Written in Gleam (compiles to Erlang/OTP)
158
+ - **Actor-based concurrency:** Millions of jobs, zero race conditions
159
+ - **Sub-millisecond job routing:** Faster than Lambda cold starts
160
+ - **Multi-region racing:** Native to our architecture (not bolted on)
161
+
162
+ **AWS Stack:**
163
+ - Lambda: Cold starts, concurrent execution limits, IAM hell
164
+ - SQS: No native retry logic, visibility timeout confusion
165
+ - DynamoDB: Hot partitions, throughput throttling
166
+ - Step Functions: $0.025 per 1000 state transitions (adds up fast)
167
+
168
+ **You can't build this on AWS serverless and get the same performance.**
169
+ We tried. It doesn't work. That's why we built EZThrottle.
170
+
171
+ ---
172
+
173
+ ## The Hidden Cost of Retry Storms
174
+
175
+ **What happens when Stripe/OpenAI/Anthropic has an outage?**
176
+
177
+ ### Without EZThrottle:
178
+
179
+ **5-minute API outage causes:**
180
+ ```
181
+ 1000 req/sec × 5 retries = 5000 req/sec retry storm
182
+ 5000 req/sec × 300 seconds = 1.5M failed requests
183
+ 1.5M × 10KB payload = 15GB egress
184
+ 15GB × $0.09/GB = $1,350 in AWS egress fees
185
+
186
+ Plus:
187
+ - Lambda concurrent execution limit hit (all new requests fail)
188
+ - SQS queues backing up (visibility timeout chaos)
189
+ - DynamoDB throttling (hot partition from retry attempts)
190
+ - CloudWatch logs exploding ($200+ in 5 minutes)
191
+ - Your servers maxed out (can't serve real users)
192
+
193
+ Total cost: $1,550 + 2 hours of engineer time + lost revenue
194
+ ```
195
+
196
+ ### With EZThrottle:
197
+
198
+ **Same 5-minute outage:**
199
+ ```
200
+ 1000 req/sec × 1 submit to EZThrottle = 1000 req/sec
201
+ 300k requests × $0.50/1k = $150 total
202
+
203
+ Plus:
204
+ - Your servers stay healthy (serving real users)
205
+ - No retry storm (EZThrottle handles retries)
206
+ - No egress fees (one request out, webhook back)
207
+ - No debugging at 3am
208
+ - No lost revenue
209
+
210
+ Total cost: $150 + 0 engineer time + 0 lost revenue
211
+ ```
212
+
213
+ **Savings: $1,400 per outage** (and your sanity)
214
+
215
+ ---
216
+
217
+ ## The Hidden Cost of Building This Yourself
218
+
219
+ **You're about to hire 2 engineers to build retry infrastructure. Let's do the math.**
220
+
221
+ ### DIY Cost (AWS + Engineers):
222
+
223
+ | Component | Year 1 | Ongoing |
224
+ |-----------|--------|---------|
225
+ | **Infrastructure** | | |
226
+ | Lambda (retries + webhooks) | $1,200 | $1,200/year |
227
+ | SQS (job queues) | $1,200 | $1,200/year |
228
+ | DynamoDB (state tracking) | $3,000 | $3,000/year |
229
+ | CloudWatch (logs) | $1,200 | $1,200/year |
230
+ | Data transfer (egress fees) | $12,000 | $12,000/year |
231
+ | **Infrastructure subtotal** | **$18,600** | **$18,600/year** |
232
+ | | | |
233
+ | **Engineering** | | |
234
+ | Initial build (3 months, 2 engineers @ $150k) | $75,000 | - |
235
+ | Ongoing maintenance (30% time, 2 engineers) | $45,000 | $90,000/year |
236
+ | On-call rotation (outage response) | $15,000 | $30,000/year |
237
+ | **Engineering subtotal** | **$135,000** | **$120,000/year** |
238
+ | | | |
239
+ | **TOTAL DIY COST** | **$153,600** | **$138,600/year** |
240
+
241
+ ### EZThrottle Cost:
242
+
243
+ | Component | Year 1 | Ongoing |
244
+ |-----------|--------|---------|
245
+ | Free tier (1M requests/month) | $0 | $0/year |
246
+ | Pro tier (2M requests/month) | $6,000 | $6,000/year |
247
+ | Engineer time to integrate | $5,000 | $0/year |
248
+ | **TOTAL EZTHROTTLE COST** | **$11,000** | **$6,000/year** |
249
+
250
+ **Savings: $142,600 in Year 1, $132,600/year ongoing**
251
+
252
+ Or put another way: **You save an entire senior engineer's salary every year.**
253
+
254
+ ---
255
+
256
+ ## FRUGAL vs PERFORMANCE: Choose Your Strategy
257
+
258
+ | Feature | FRUGAL | PERFORMANCE |
259
+ |---------|--------|-------------|
260
+ | **Execution** | Client-side first | Server-side distributed |
261
+ | **When to use** | High success rate (95%+) | Mission-critical / high traffic |
262
+ | **Cost** | Only pay when forwarded | Always uses EZThrottle |
263
+ | **During API outages** | Retry storm (melts your servers) | Servers stay healthy |
264
+ | **Egress fees** | High (every retry = AWS egress) | Low (one request to EZThrottle) |
265
+ | **Lambda limits** | Hit concurrent execution cap | Never hit limits |
266
+ | **IAM debugging** | Your problem | Not your problem |
267
+ | **Good night's sleep** | Nope | Yes |
268
+
269
+ ### Rate Limiting: 2 RPS Per Domain
15
270
 
16
- const client = new EZThrottle({
17
- apiKey: 'ck_live_cust_XXX_YYY',
271
+ EZThrottle throttles at **2 requests per second PER TARGET DOMAIN**:
272
+
273
+ - `api.stripe.com` → 2 RPS
274
+ - `api.openai.com` → 2 RPS
275
+ - `api.anthropic.com` → 2 RPS
276
+
277
+ All domains run concurrently. The limit is per destination, not per account.
278
+
279
+ **Need higher limits?** Return `X-EZTHROTTLE-RPS` header or [request custom defaults](https://github.com/rjpruitt16/ezconfig).
280
+
281
+ ---
282
+
283
+ ## Real-World Example: Payment Processor
284
+
285
+ **Before EZThrottle (AWS Lambda + SQS):**
286
+ - Stripe outage: 15 minutes
287
+ - Retry storm: 2M failed requests
288
+ - AWS egress fees: $1,800
289
+ - Lambda concurrent execution limit hit: 45 minutes total downtime
290
+ - Lost revenue: $50,000
291
+ - Engineer time debugging: 6 hours (including 3am wake-up)
292
+ - CloudWatch logs: $400
293
+ - Customer support tickets: 200
294
+ - **Total cost per outage: $52,200**
295
+
296
+ **After EZThrottle:**
297
+ - Same Stripe outage: 15 minutes
298
+ - Submitted to EZThrottle: 300k requests
299
+ - EZThrottle cost: $150
300
+ - Servers stayed online: 0 minutes downtime
301
+ - Lost revenue: $0
302
+ - Engineer time: 0 hours (slept through it)
303
+ - Customer support tickets: 5
304
+ - **Total cost per outage: $150**
305
+
306
+ **ROI: 348x cost reduction per outage**
307
+
308
+ Plus ongoing savings:
309
+ - 60% reduction in AWS egress fees ($7,200/year saved)
310
+ - Zero Lambda IAM debugging (priceless)
311
+ - No more 3am pages (actually priceless)
312
+ - One less engineer needed ($150k/year saved)
313
+
314
+ ---
315
+
316
+ ## What You're Really Paying For
317
+
318
+ ❌ **Wrong comparison:** "EZThrottle ($500/1M) vs Lambda ($0.20/1M)"
319
+ → This ignores SQS, DynamoDB, egress, IAM hell, and engineers
320
+
321
+ ✅ **Right comparison:** "EZThrottle ($6k/year) vs DIY ($139k/year)"
322
+ → Lambda + SQS + DynamoDB + engineers + sanity
323
+
324
+ **You're not paying for request proxying.**
325
+ **You're paying to never debug Lambda IAM policies at 3am again.**
326
+
327
+ **What you get:**
328
+ - ✅ No retry storms during API outages
329
+ - ✅ No Lambda concurrent execution limits
330
+ - ✅ No IAM policy debugging hell
331
+ - ✅ No SQS dead letter queue mysteries
332
+ - ✅ No DynamoDB hot partition throttling
333
+ - ✅ Multi-region racing (3+ regions, fastest wins)
334
+ - ✅ Webhook reliability (automatic retries)
335
+ - ✅ Built in Gleam/OTP (actor-based, zero race conditions)
336
+ - ✅ Sleep through outages (we handle it)
337
+
338
+ **AWS can't do this at this scale. That's why EZThrottle exists.**
339
+
340
+ ---
341
+
342
+ # SDK Documentation
343
+
344
+ ## Step Types
345
+
346
+ ### StepType.PERFORMANCE (Server-side execution)
347
+
348
+ Submit jobs to EZThrottle for distributed execution with multi-region racing and webhook delivery.
349
+
350
+ ```javascript
351
+ await new Step(client)
352
+ .url('https://api.stripe.com/charges')
353
+ .type(StepType.PERFORMANCE)
354
+ .webhooks([{ url: 'https://app.com/webhook' }])
355
+ .regions(['iad', 'lax', 'ord']) // Multi-region racing
356
+ .executionMode('race') // First completion wins
357
+ .execute();
358
+ ```
359
+
360
+ ### StepType.FRUGAL (Client-side first)
361
+
362
+ Execute locally first, only forward to EZThrottle on specific error codes. Saves money!
363
+
364
+ ```javascript
365
+ await new Step(client)
366
+ .url('https://api.example.com')
367
+ .type(StepType.FRUGAL)
368
+ .fallbackOnError([429, 500, 503]) // Forward to EZThrottle on these codes
369
+ .execute();
370
+ ```
371
+
372
+ ## Idempotent Key Strategies
373
+
374
+ **Critical concept:** Idempotent keys prevent duplicate job execution. Choose the right strategy for your use case.
375
+
376
+ ### IdempotentStrategy.HASH (Default)
377
+
378
+ Backend generates deterministic hash of (url, method, body, customer_id). **Prevents duplicates.**
379
+
380
+ **Use when:**
381
+ - Payment processing (don't charge twice!)
382
+ - Critical operations (create user, send notification)
383
+ - You want automatic deduplication
384
+
385
+ **Example:**
386
+ ```javascript
387
+ const { IdempotentStrategy } = require('ezthrottle');
388
+
389
+ // Prevents duplicate charges - same request = rejected as duplicate
390
+ await new Step(client)
391
+ .url('https://api.stripe.com/charges')
392
+ .body(JSON.stringify({ amount: 1000, currency: 'usd' }))
393
+ .idempotentStrategy(IdempotentStrategy.HASH) // Default
394
+ .execute();
395
+ ```
396
+
397
+ ### IdempotentStrategy.UNIQUE
398
+
399
+ SDK generates unique UUID per request. **Allows duplicates.**
400
+
401
+ **Use when:**
402
+ - Polling endpoints (same URL, different data each time)
403
+ - Webhooks (want to send every time)
404
+ - Scheduled jobs (run every minute/hour)
405
+ - GET requests that return changing data
406
+
407
+ **Example:**
408
+ ```javascript
409
+ // Poll API every minute - each request gets unique UUID
410
+ setInterval(async () => {
411
+ await new Step(client)
412
+ .url('https://api.example.com/status')
413
+ .idempotentStrategy(IdempotentStrategy.UNIQUE) // New UUID each time
414
+ .execute();
415
+ }, 60000);
416
+ ```
417
+
418
+ ## Workflow Chaining
419
+
420
+ Chain steps together with `.onSuccess()`, `.onFailure()`, and `.fallback()`:
421
+
422
+ ```javascript
423
+ // Analytics step (cheap)
424
+ const analytics = new Step(client)
425
+ .url('https://analytics.com/track')
426
+ .type(StepType.FRUGAL);
427
+
428
+ // Notification (fast, distributed)
429
+ const notification = new Step(client)
430
+ .url('https://notify.com')
431
+ .type(StepType.PERFORMANCE)
432
+ .webhooks([{ url: 'https://app.com/webhook' }])
433
+ .regions(['iad', 'lax'])
434
+ .onSuccess(analytics);
435
+
436
+ // Primary API call (cheap local execution)
437
+ await new Step(client)
438
+ .url('https://api.example.com')
439
+ .type(StepType.FRUGAL)
440
+ .fallbackOnError([429, 500])
441
+ .onSuccess(notification)
442
+ .execute();
443
+ ```
444
+
445
+ ## Fallback Chains
446
+
447
+ Handle failures with automatic fallback execution:
448
+
449
+ ```javascript
450
+ const backupApi = new Step().url('https://backup-api.com');
451
+
452
+ await new Step(client)
453
+ .url('https://primary-api.com')
454
+ .fallback(backupApi, { triggerOnError: [500, 502, 503] })
455
+ .execute();
456
+ ```
457
+
458
+ ## Multi-Region Racing
459
+
460
+ Submit jobs to multiple regions, fastest wins:
461
+
462
+ ```javascript
463
+ await new Step(client)
464
+ .url('https://api.example.com')
465
+ .regions(['iad', 'lax', 'ord']) // Try all 3 regions
466
+ .regionPolicy('fallback') // Auto-route if region down
467
+ .executionMode('race') // First completion wins
468
+ .webhooks([{ url: 'https://app.com/webhook' }])
469
+ .execute();
470
+ ```
471
+
472
+ ## Webhook Fanout (Multiple Webhooks)
473
+
474
+ Deliver job results to multiple services simultaneously:
475
+
476
+ ```javascript
477
+ await new Step(client)
478
+ .url('https://api.stripe.com/charges')
479
+ .method('POST')
480
+ .webhooks([
481
+ // Primary webhook (must succeed)
482
+ { url: 'https://app.com/payment-complete', has_quorum_vote: true },
483
+
484
+ // Analytics webhook (optional)
485
+ { url: 'https://analytics.com/track', has_quorum_vote: false },
486
+
487
+ // Notification service (must succeed)
488
+ { url: 'https://notify.com/alert', has_quorum_vote: true },
489
+
490
+ // Multi-region webhook racing
491
+ { url: 'https://backup.com/webhook', regions: ['iad', 'lax'], has_quorum_vote: true }
492
+ ])
493
+ .webhookQuorum(2) // At least 2 webhooks with has_quorum_vote=true must succeed
494
+ .execute();
495
+ ```
496
+
497
+ ## Retry Policies
498
+
499
+ Customize retry behavior:
500
+
501
+ ```javascript
502
+ await new Step(client)
503
+ .url('https://api.example.com')
504
+ .retryPolicy({
505
+ max_retries: 5,
506
+ max_reroutes: 3,
507
+ retry_codes: [429, 503], // Retry in same region
508
+ reroute_codes: [500, 502, 504] // Try different region
509
+ })
510
+ .execute();
511
+ ```
512
+
513
+ ## Rate Limiting & Tuning
514
+
515
+ EZThrottle intelligently manages rate limits for your API calls. By default, requests are throttled at **2 RPS (requests per second)** to smooth rate limiting across distributed workers and prevent API overload.
516
+
517
+ ### Dynamic Rate Limiting via Response Headers
518
+
519
+ Your API can communicate rate limits back to EZThrottle using response headers:
520
+
521
+ ```javascript
522
+ // Your API responds with these headers:
523
+ X-EZTHROTTLE-RPS: 5 // Allow 5 requests per second
524
+ X-EZTHROTTLE-MAX-CONCURRENT: 10 // Allow 10 concurrent requests
525
+ ```
526
+
527
+ **Header Details:**
528
+ - `X-EZTHROTTLE-RPS`: Requests per second (e.g., `0.5` = 1 request per 2 seconds, `5` = 5 requests per second)
529
+ - `X-EZTHROTTLE-MAX-CONCURRENT`: Maximum concurrent requests (default: 2 per machine)
530
+
531
+ EZThrottle automatically adjusts its rate limiting based on these headers, ensuring optimal throughput without overwhelming your APIs.
532
+
533
+ **Performance Note:** Server-side retry handling is significantly faster and more performant than client-side retry loops. EZThrottle's distributed architecture eliminates connection overhead and retry latency. *Benchmarks coming soon.*
534
+
535
+ ### Requesting Custom Defaults
536
+
537
+ Need different default rate limits for your account? Submit a configuration request:
538
+
539
+ 👉 **[Request custom defaults at github.com/rjpruitt16/ezconfig](https://github.com/rjpruitt16/ezconfig)**
540
+
541
+ ## Webhook Payload
542
+
543
+ When EZThrottle completes your job, it sends a POST request to your webhook URL with the following JSON payload:
544
+
545
+ ```json
546
+ {
547
+ "job_id": "job_1763674210055_853341",
548
+ "idempotent_key": "custom_key_or_generated_hash",
549
+ "status": "success",
550
+ "response": {
551
+ "status_code": 200,
552
+ "headers": {
553
+ "content-type": "application/json"
554
+ },
555
+ "body": "{\"result\": \"data\"}"
556
+ },
557
+ "metadata": {}
558
+ }
559
+ ```
560
+
561
+ **Fields:**
562
+ - `job_id` - Unique identifier for this job
563
+ - `idempotent_key` - Your custom key or auto-generated hash
564
+ - `status` - `"success"` or `"failed"`
565
+ - `response.status_code` - HTTP status code from the target API
566
+ - `response.headers` - Response headers from the target API
567
+ - `response.body` - Response body from the target API (as string)
568
+ - `metadata` - Custom metadata you provided during job submission
569
+
570
+ **Example webhook handler (Express):**
571
+ ```javascript
572
+ const express = require('express');
573
+ const app = express();
574
+
575
+ app.use(express.json());
576
+
577
+ app.post('/webhook', (req, res) => {
578
+ const payload = req.body;
579
+
580
+ const jobId = payload.job_id;
581
+ const status = payload.status;
582
+
583
+ if (status === 'success') {
584
+ const responseBody = payload.response.body;
585
+ // Process successful result
586
+ console.log(`Job ${jobId} succeeded:`, responseBody);
587
+ } else {
588
+ // Handle failure
589
+ console.log(`Job ${jobId} failed`);
590
+ }
591
+
592
+ res.json({ ok: true });
593
+ });
594
+ ```
595
+
596
+ ## Webhook Security (HMAC Signatures)
597
+
598
+ Protect webhooks from spoofing with HMAC-SHA256 signature verification.
599
+
600
+ ### Quick Setup
601
+
602
+ ```typescript
603
+ import express from 'express';
604
+ import { EZThrottle, verifyWebhookSignatureStrict, WebhookVerificationError } from 'ezthrottle';
605
+
606
+ const app = express();
607
+ const client = new EZThrottle({ apiKey: 'your_api_key' });
608
+ const WEBHOOK_SECRET = 'your_secret_min_16_chars';
609
+
610
+ // Create secret (one time)
611
+ await client.createWebhookSecret('your_secret_min_16_chars');
612
+
613
+ // Verify webhooks
614
+ app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
615
+ try {
616
+ verifyWebhookSignatureStrict(
617
+ req.body,
618
+ req.headers['x-ezthrottle-signature'] as string,
619
+ WEBHOOK_SECRET
620
+ );
621
+
622
+ const data = JSON.parse(req.body.toString());
623
+ console.log(`Job ${data.job_id}: ${data.status}`);
624
+ res.json({ ok: true });
625
+ } catch (error) {
626
+ if (error instanceof WebhookVerificationError) {
627
+ return res.status(401).json({ error: error.message });
628
+ }
629
+ throw error;
630
+ }
631
+ });
632
+ ```
633
+
634
+ ### Verification Functions
635
+
636
+ ```typescript
637
+ import { verifyWebhookSignature, tryVerifyWithSecrets } from 'ezthrottle';
638
+
639
+ // Boolean verification
640
+ const { verified, reason } = verifyWebhookSignature(payload, signature, secret);
641
+ if (!verified) console.log(`Failed: ${reason}`);
642
+
643
+ // Secret rotation support
644
+ const result = tryVerifyWithSecrets(
645
+ payload,
646
+ signature,
647
+ 'new_secret',
648
+ 'old_secret' // Optional
649
+ );
650
+ console.log(result.reason); // "valid_primary" or "valid_secondary"
651
+ ```
652
+
653
+ ### Manage Secrets
654
+
655
+ ```typescript
656
+ // Create/update
657
+ await client.createWebhookSecret('primary_secret', 'secondary_secret');
658
+
659
+ // Get (masked)
660
+ const secrets = await client.getWebhookSecret();
661
+ // { primary_secret: 'prim****cret', has_secondary: true }
662
+
663
+ // Rotate safely
664
+ await client.rotateWebhookSecret('new_secret');
665
+
666
+ // Delete
667
+ await client.deleteWebhookSecret();
668
+ ```
669
+
670
+ ### Quick Commands (One-Liners)
671
+
672
+ ```bash
673
+ # Create secret
674
+ node -e "const {EZThrottle}=require('ezthrottle'); new EZThrottle({apiKey:'key'}).createWebhookSecret('secret').then(console.log)"
675
+
676
+ # Get secrets
677
+ node -e "const {EZThrottle}=require('ezthrottle'); new EZThrottle({apiKey:'key'}).getWebhookSecret().then(r=>console.log(JSON.stringify(r,null,2)))"
678
+
679
+ # Rotate secret
680
+ node -e "const {EZThrottle}=require('ezthrottle'); new EZThrottle({apiKey:'key'}).rotateWebhookSecret('new_secret').then(console.log)"
681
+
682
+ # Delete
683
+ node -e "const {EZThrottle}=require('ezthrottle'); new EZThrottle({apiKey:'key'}).deleteWebhookSecret().then(console.log)"
684
+
685
+ # With env var
686
+ export EZTHROTTLE_API_KEY="your_key"
687
+ node -e "const {EZThrottle}=require('ezthrottle'); new EZThrottle({apiKey:process.env.EZTHROTTLE_API_KEY}).createWebhookSecret('secret').then(console.log)"
688
+ ```
689
+
690
+ ### Best Practices
691
+
692
+ 1. Always verify signatures in production
693
+ 2. Use 32+ character random secrets
694
+ 3. Rotate secrets periodically with primary + secondary
695
+ 4. Store secrets in environment variables
696
+
697
+ ## Mixed Workflow Chains (FRUGAL ↔ PERFORMANCE)
698
+
699
+ Mix FRUGAL and PERFORMANCE steps in the same workflow to optimize for both cost and speed:
700
+
701
+ ### Example 1: FRUGAL → PERFORMANCE (Save money, then fast delivery)
702
+
703
+ ```javascript
704
+ // Primary API call is cheap (local execution)
705
+ // But notification needs speed (multi-region racing)
706
+ const result = await new Step(client)
707
+ .url('https://api.openai.com/v1/chat/completions')
708
+ .type(StepType.FRUGAL) // Execute locally first
709
+ .fallbackOnError([429, 500])
710
+ .onSuccess(
711
+ // Chain to PERFORMANCE for fast webhook delivery
712
+ new Step(client)
713
+ .url('https://api.sendgrid.com/send')
714
+ .type(StepType.PERFORMANCE) // Distributed execution
715
+ .webhooks([{ url: 'https://app.com/email-sent' }])
716
+ .regions(['iad', 'lax', 'ord'])
717
+ )
718
+ .execute();
719
+ ```
720
+
721
+ ### Example 2: PERFORMANCE → FRUGAL (Fast payment, then cheap analytics)
722
+
723
+ ```javascript
724
+ // Critical payment needs speed (racing)
725
+ // But analytics is cheap (local execution when webhook arrives)
726
+ const payment = await new Step(client)
727
+ .url('https://api.stripe.com/charges')
728
+ .type(StepType.PERFORMANCE) // Fast distributed execution
729
+ .webhooks([{ url: 'https://app.com/payment-complete' }])
730
+ .regions(['iad', 'lax'])
731
+ .onSuccess(
732
+ // Analytics doesn't need speed - save money!
733
+ new Step(client)
734
+ .url('https://analytics.com/track')
735
+ .type(StepType.FRUGAL) // Client executes when webhook arrives
736
+ )
737
+ .execute();
738
+ ```
739
+
740
+ ### Example 3: Complex Mixed Workflow
741
+
742
+ ```javascript
743
+ // Optimize every step for its requirements
744
+ const workflow = await new Step(client)
745
+ .url('https://cheap-api.com')
746
+ .type(StepType.FRUGAL) // Try locally first
747
+ .fallbackOnError([429, 500])
748
+ .fallback(
749
+ new Step().url('https://backup-api.com'), // Still FRUGAL
750
+ { triggerOnError: [500] }
751
+ )
752
+ .onSuccess(
753
+ // Critical notification needs PERFORMANCE
754
+ new Step(client)
755
+ .url('https://critical-webhook.com')
756
+ .type(StepType.PERFORMANCE)
757
+ .webhooks([{ url: 'https://app.com/webhook' }])
758
+ .regions(['iad', 'lax', 'ord'])
759
+ .onSuccess(
760
+ // Analytics is cheap again
761
+ new Step(client)
762
+ .url('https://analytics.com/track')
763
+ .type(StepType.FRUGAL)
764
+ )
765
+ )
766
+ .onFailure(
767
+ // Simple Slack alert doesn't need PERFORMANCE
768
+ new Step(client)
769
+ .url('https://hooks.slack.com/webhook')
770
+ .type(StepType.FRUGAL)
771
+ )
772
+ .execute();
773
+ ```
774
+
775
+ **Why mix workflows?**
776
+ - ✅ **Cost optimization** - Only pay for what needs speed
777
+ - ✅ **Performance where it matters** - Critical paths get multi-region racing
778
+ - ✅ **Flexibility** - Every step optimized for its specific requirements
779
+
780
+ ## Production Ready ✅
781
+
782
+ This SDK is production-ready with **working examples validated in CI on every push**.
783
+
784
+ ### Reference Implementation: test-app/
785
+
786
+ The `test-app/` directory contains **real, working code** you can learn from. Not toy examples - this is production code we run in automated tests against live EZThrottle backend.
787
+
788
+ **Multi-Region Racing** ([test-app/app.js:104-122](test-app/app.js#L104-L122))
789
+ ```javascript
790
+ await new Step(client)
791
+ .url('https://httpbin.org/delay/1')
792
+ .type(StepType.PERFORMANCE)
793
+ .webhooks([{ url: `${APP_URL}/webhook` }])
794
+ .regions(['iad', 'lax', 'ord']) // Race across 3 regions
795
+ .executionMode('race') // First completion wins
796
+ .execute();
797
+ ```
798
+
799
+ **Idempotent HASH (Deduplication)** ([test-app/app.js:181-203](test-app/app.js#L181-L203))
800
+ ```javascript
801
+ // Same request twice = same job_id (deduplicated)
802
+ await new Step(client)
803
+ .url(`https://httpbin.org/get?run=${runId}`)
804
+ .idempotentStrategy(IdempotentStrategy.HASH)
805
+ .execute();
806
+ ```
807
+
808
+ **Fallback Chain** ([test-app/app.js:125-154](test-app/app.js#L125-L154))
809
+ ```javascript
810
+ await new Step(client)
811
+ .url('https://httpbin.org/status/500')
812
+ .fallback(
813
+ new Step().url('https://httpbin.org/status/200'),
814
+ { triggerOnError: [500, 502, 503] }
815
+ )
816
+ .execute();
817
+ ```
818
+
819
+ **On-Success Workflow** ([test-app/app.js:157-178](test-app/app.js#L157-L178))
820
+ ```javascript
821
+ await new Step(client)
822
+ .url('https://httpbin.org/status/200')
823
+ .onSuccess(
824
+ new Step().url('https://httpbin.org/delay/1')
825
+ )
826
+ .execute();
827
+ ```
828
+
829
+ **FRUGAL Local Execution** ([test-app/app.js:247-260](test-app/app.js#L247-L260))
830
+ ```javascript
831
+ await new Step(client)
832
+ .url('https://httpbin.org/status/200')
833
+ .type(StepType.FRUGAL)
834
+ .execute();
835
+ ```
836
+
837
+ **Validated in CI:**
838
+ - ✅ GitHub Actions runs these examples against live backend on every push
839
+ - ✅ 7 integration tests covering all SDK features
840
+ - ✅ Proves the code actually works, not just documentation
841
+
842
+ ## Legacy Code Integration (executeWithForwarding)
843
+
844
+ Integrate EZThrottle into existing codebases without refactoring error handling. Return `{ forward: ForwardRequest }` from your legacy functions to trigger automatic forwarding to EZThrottle.
845
+
846
+ ```javascript
847
+ const { executeWithForwarding, StepType } = require('ezthrottle');
848
+
849
+ // Legacy function that may hit rate limits
850
+ async function processPayment(orderId) {
851
+ try {
852
+ const response = await fetch('https://api.stripe.com/charges', {
853
+ method: 'POST',
854
+ headers: { 'Authorization': `Bearer ${STRIPE_KEY}` },
855
+ body: JSON.stringify({ amount: 1000, currency: 'usd' })
856
+ });
857
+
858
+ if (response.status === 429) {
859
+ // Rate limited - return ForwardRequest to auto-forward to EZThrottle
860
+ return {
861
+ forward: {
862
+ url: 'https://api.stripe.com/charges',
863
+ method: 'POST',
864
+ idempotentKey: `order_${orderId}`,
865
+ webhooks: [{ url: 'https://app.com/webhook', hasQuorumVote: true }],
866
+ stepType: StepType.FRUGAL
867
+ }
868
+ };
869
+ }
870
+
871
+ return await response.json();
872
+ } catch (error) {
873
+ // Network error - auto-forward to EZThrottle
874
+ return {
875
+ forward: {
876
+ url: 'https://api.stripe.com/charges',
877
+ method: 'POST',
878
+ idempotentKey: `order_${orderId}`
879
+ }
880
+ };
881
+ }
882
+ }
883
+
884
+ // Wrap with auto-forwarding
885
+ const result = await executeWithForwarding(client, () => processPayment('order_123'));
886
+ console.log(result); // Either direct response or EZThrottle job metadata
887
+ ```
888
+
889
+ ### Decorator-Style Wrapper
890
+
891
+ Create wrapped functions that automatically forward on errors:
892
+
893
+ ```javascript
894
+ const { withAutoForward } = require('ezthrottle');
895
+
896
+ // Wrap once
897
+ const processPaymentWithForwarding = withAutoForward(client, processPayment);
898
+
899
+ // Use everywhere
900
+ const result1 = await processPaymentWithForwarding('order_123');
901
+ const result2 = await processPaymentWithForwarding('order_456');
902
+ ```
903
+
904
+ ## Async/Await Streaming (Non-Blocking Webhook Waiting)
905
+
906
+ Wait for webhook results asynchronously without blocking your application. Perfect for workflows that need to continue processing while waiting for EZThrottle to complete jobs.
907
+
908
+ ### Basic Async Example
909
+
910
+ ```javascript
911
+ const { Step, StepType } = require('ezthrottle');
912
+
913
+ async function processWithWebhook() {
914
+ // Submit job to EZThrottle
915
+ const result = await new Step(client)
916
+ .url('https://api.example.com/endpoint')
917
+ .method('POST')
918
+ .type(StepType.PERFORMANCE)
919
+ .webhooks([{ url: 'https://app.com/webhook', hasQuorumVote: true }])
920
+ .idempotentKey('async_job_123')
921
+ .execute();
922
+
923
+ console.log(`Job submitted: ${result.job_id}`);
924
+
925
+ // Continue processing while EZThrottle executes the job
926
+ // Your webhook endpoint will receive the result asynchronously
927
+ }
928
+
929
+ // Non-blocking execution
930
+ processWithWebhook().then(() => {
931
+ console.log('Job submission complete, continuing with other work...');
18
932
  });
933
+ ```
19
934
 
20
- const result = await client.queueRequest({
21
- url: 'https://api.example.com/data',
22
- webhookUrl: 'https://myapp.com/webhook',
935
+ ### Concurrent Job Submission
936
+
937
+ Submit multiple jobs concurrently and process results as they arrive:
938
+
939
+ ```javascript
940
+ async function processBatchConcurrently(orders) {
941
+ // Submit all jobs concurrently
942
+ const promises = orders.map(async (order) => {
943
+ const result = await new Step(client)
944
+ .url(`https://api.example.com/process`)
945
+ .method('POST')
946
+ .body(JSON.stringify(order))
947
+ .type(StepType.PERFORMANCE)
948
+ .webhooks([{ url: 'https://app.com/webhook', hasQuorumVote: true }])
949
+ .idempotentKey(`order_${order.id}`)
950
+ .execute();
951
+
952
+ return {
953
+ orderId: order.id,
954
+ jobId: result.job_id,
955
+ idempotentKey: result.idempotent_key
956
+ };
957
+ });
958
+
959
+ // Wait for all submissions to complete
960
+ const submissions = await Promise.all(promises);
961
+
962
+ console.log(`Submitted ${submissions.length} jobs concurrently`);
963
+ submissions.forEach(s => {
964
+ console.log(`Order ${s.orderId} → Job ${s.jobId}`);
965
+ });
966
+
967
+ // Webhook results will arrive asynchronously at https://app.com/webhook
968
+ return submissions;
969
+ }
970
+
971
+ // Example usage
972
+ const orders = [
973
+ { id: 'order_1', amount: 1000 },
974
+ { id: 'order_2', amount: 2000 },
975
+ { id: 'order_3', amount: 3000 }
976
+ ];
977
+
978
+ processBatchConcurrently(orders).then(submissions => {
979
+ console.log('All jobs submitted, processing continues...');
23
980
  });
981
+ ```
24
982
 
25
- console.log('Job queued:', result.job_id);
983
+ ### Promise.allSettled for Fault Tolerance
984
+
985
+ Handle failures gracefully when submitting multiple jobs:
986
+
987
+ ```javascript
988
+ async function processBatchWithErrorHandling(orders) {
989
+ const promises = orders.map(async (order) => {
990
+ try {
991
+ const result = await new Step(client)
992
+ .url(`https://api.example.com/process`)
993
+ .method('POST')
994
+ .body(JSON.stringify(order))
995
+ .type(StepType.PERFORMANCE)
996
+ .webhooks([{ url: 'https://app.com/webhook', hasQuorumVote: true }])
997
+ .idempotentKey(`order_${order.id}`)
998
+ .execute();
999
+
1000
+ return { orderId: order.id, jobId: result.job_id };
1001
+ } catch (error) {
1002
+ return { orderId: order.id, error: error.message };
1003
+ }
1004
+ });
1005
+
1006
+ // Wait for all promises to settle (success or failure)
1007
+ const results = await Promise.allSettled(promises);
1008
+
1009
+ const succeeded = results.filter(r => r.status === 'fulfilled' && !r.value.error);
1010
+ const failed = results.filter(r => r.status === 'rejected' || r.value?.error);
1011
+
1012
+ console.log(`Succeeded: ${succeeded.length}, Failed: ${failed.length}`);
1013
+
1014
+ return { succeeded, failed };
1015
+ }
1016
+ ```
1017
+
1018
+ ### Integration with Express Webhook Handler
1019
+
1020
+ ```javascript
1021
+ const express = require('express');
1022
+ const app = express();
1023
+
1024
+ app.use(express.json());
1025
+
1026
+ // In-memory store for webhook results (use Redis/DB in production)
1027
+ const webhookResults = new Map();
1028
+
1029
+ // Webhook receiver
1030
+ app.post('/webhook', (req, res) => {
1031
+ const { job_id, idempotent_key, status, response } = req.body;
1032
+
1033
+ // Store result for polling or processing
1034
+ webhookResults.set(idempotent_key, {
1035
+ jobId: job_id,
1036
+ status,
1037
+ response,
1038
+ receivedAt: new Date()
1039
+ });
1040
+
1041
+ console.log(`Webhook received for ${idempotent_key}: ${status}`);
1042
+
1043
+ res.json({ ok: true });
1044
+ });
1045
+
1046
+ // Submit job and continue processing
1047
+ app.post('/submit', async (req, res) => {
1048
+ const idempotentKey = `job_${Date.now()}`;
1049
+
1050
+ const result = await new Step(client)
1051
+ .url('https://api.example.com/endpoint')
1052
+ .method('POST')
1053
+ .type(StepType.PERFORMANCE)
1054
+ .webhooks([{ url: 'https://app.com/webhook', hasQuorumVote: true }])
1055
+ .idempotentKey(idempotentKey)
1056
+ .execute();
1057
+
1058
+ // Return immediately, don't wait for webhook
1059
+ res.json({
1060
+ jobId: result.job_id,
1061
+ idempotentKey: idempotentKey,
1062
+ message: 'Job submitted, webhook will arrive asynchronously'
1063
+ });
1064
+ });
1065
+
1066
+ // Poll for webhook result
1067
+ app.get('/result/:idempotentKey', (req, res) => {
1068
+ const result = webhookResults.get(req.params.idempotentKey);
1069
+
1070
+ if (result) {
1071
+ res.json({ found: true, result });
1072
+ } else {
1073
+ res.json({ found: false, message: 'Webhook not yet received' });
1074
+ }
1075
+ });
1076
+
1077
+ app.listen(3000, () => console.log('Server listening on port 3000'));
1078
+ ```
1079
+
1080
+ ## Legacy API (Deprecated)
1081
+
1082
+ For backward compatibility, the old `queueRequest()` method is still available:
1083
+
1084
+ ```javascript
1085
+ await client.queueRequest({
1086
+ url: 'https://api.example.com',
1087
+ webhookUrl: 'https://your-app.com/webhook', // Note: singular
1088
+ method: 'POST'
1089
+ });
1090
+ ```
1091
+
1092
+ **Prefer the new `Step` builder API for all new code!**
1093
+
1094
+ ---
1095
+
1096
+ # Appendix
1097
+
1098
+ ## Environment Variables
1099
+
1100
+ ```bash
1101
+ EZTHROTTLE_API_KEY=your_api_key_here
26
1102
  ```
27
1103
 
28
1104
  ## License