bridgex 2.0.1 → 2.1.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.
@@ -1,4 +1,15 @@
1
- import { NetworkError, ServerError } from "./errors.js";
1
+ import { NetworkError, ServerError, RateLimitError, MaxLimitError, } from "./errors.js";
2
+ // Patterns that indicate a "max messages sent" quota error from the server
3
+ const MAX_LIMIT_PATTERNS = [
4
+ /max.{0,20}(messages?|sms|send)/i,
5
+ /quota.{0,20}(exceeded|reached|exhausted)/i,
6
+ /limit.{0,20}reached/i,
7
+ /daily.{0,20}(limit|cap)/i,
8
+ /monthly.{0,20}(limit|cap)/i,
9
+ ];
10
+ function isMaxLimitBody(text) {
11
+ return MAX_LIMIT_PATTERNS.some((p) => p.test(text));
12
+ }
2
13
  export default class HttpClient {
3
14
  constructor(options) {
4
15
  this.options = options;
@@ -19,17 +30,33 @@ export default class HttpClient {
19
30
  signal: controller.signal,
20
31
  });
21
32
  clearTimeout(timeoutId);
33
+ if (response.status === 429) {
34
+ const retryAfter = Number(response.headers.get("Retry-After")) || undefined;
35
+ throw new RateLimitError("Rate limit exceeded", retryAfter);
36
+ }
22
37
  if (!response.ok) {
23
38
  const text = await response.text();
24
- throw new ServerError(`Server responded with ${response.status}`, text);
39
+ // Detect "max messages sent reached" specifically — not retryable
40
+ if (response.status === 402 ||
41
+ response.status === 403 ||
42
+ isMaxLimitBody(text)) {
43
+ throw new MaxLimitError(`Message quota exceeded (HTTP ${response.status})`, { status: response.status, body: text });
44
+ }
45
+ throw new ServerError(`Server responded with ${response.status}`, {
46
+ status: response.status,
47
+ body: text,
48
+ });
25
49
  }
26
50
  return await response.json();
27
51
  }
28
52
  catch (error) {
53
+ clearTimeout(timeoutId);
29
54
  if (error.name === "AbortError") {
30
55
  throw new NetworkError("Request timed out");
31
56
  }
32
- if (error instanceof ServerError) {
57
+ if (error instanceof ServerError ||
58
+ error instanceof RateLimitError ||
59
+ error instanceof MaxLimitError) {
33
60
  throw error;
34
61
  }
35
62
  throw new NetworkError("Network request failed", error);
@@ -25,19 +25,22 @@ export default class SMSClient {
25
25
  private concurrency;
26
26
  private batchSize;
27
27
  constructor(options: SMSClientOptions);
28
- /** Attach a plugin at any time. */
29
28
  use(plugin: Plugin): this;
30
29
  private _send;
31
- /**
32
- * Send a single SMS message.
33
- * Returns a Result — never throws.
34
- */
30
+ /** Send a single SMS. Returns a Result — never throws. */
35
31
  send(params: SendParams & {
36
32
  _meta?: any;
37
33
  }): Promise<Result<any>>;
38
34
  /**
39
35
  * Send the same template to many recipients.
40
- * Each recipient can supply its own variables; shared variables are the fallback.
36
+ *
37
+ * Detailed batch report:
38
+ * - succeeded[]: index, to, data (the server's response JSON)
39
+ * - failed[]: index, to, error (typed ErrorLog), originalParams (full params preserved)
40
+ * - hitMaxLimit: true if the server signalled quota exhausted — stops sending immediately
41
+ *
42
+ * Failed recipients' originalParams can be passed straight to queue.enqueue()
43
+ * so nothing is lost.
41
44
  */
42
45
  sendMany(recipients: Array<{
43
46
  to: string;
@@ -51,15 +54,11 @@ export default class SMSClient {
51
54
  sendObject<T extends Record<string, unknown>>(params: SendObjectParams<T> & {
52
55
  _meta?: any;
53
56
  }): Promise<Result<any>>;
54
- /**
55
- * Send object-derived messages to many recipients.
56
- */
57
+ /** Send object-derived messages to many recipients. */
57
58
  sendObjectMany<T extends Record<string, unknown>>(items: Array<SendObjectParams<T> & {
58
59
  _meta?: any;
59
60
  }>): Promise<BatchResult<any>>;
60
- /** Current circuit breaker state. */
61
61
  get circuitState(): "CLOSED" | "OPEN" | "HALF_OPEN";
62
- /** Manually reset the circuit breaker (e.g. after fixing a downstream issue). */
63
62
  resetCircuit(): void;
64
63
  private validateOptions;
65
64
  }
package/dist/SMSClient.js CHANGED
@@ -50,13 +50,12 @@ export default class SMSClient {
50
50
  this.batchSize = options.batchSize ?? 50;
51
51
  options.plugins?.forEach((p) => this.plugins.use(p));
52
52
  }
53
- /** Attach a plugin at any time. */
54
53
  use(plugin) {
55
54
  this.plugins.use(plugin);
56
55
  return this;
57
56
  }
58
57
  // ─────────────────────────────────────────────────────────────────────────
59
- // Core internal send — all public methods funnel through here
58
+ // Core internal send
60
59
  // ─────────────────────────────────────────────────────────────────────────
61
60
  async _send(to, message, tags = []) {
62
61
  const start = Date.now();
@@ -86,10 +85,7 @@ export default class SMSClient {
86
85
  // ─────────────────────────────────────────────────────────────────────────
87
86
  // Public API
88
87
  // ─────────────────────────────────────────────────────────────────────────
89
- /**
90
- * Send a single SMS message.
91
- * Returns a Result — never throws.
92
- */
88
+ /** Send a single SMS. Returns a Result — never throws. */
93
89
  async send(params) {
94
90
  const { to, template, variables = {} } = params;
95
91
  const tags = params._meta?.tags ?? [];
@@ -106,7 +102,14 @@ export default class SMSClient {
106
102
  }
107
103
  /**
108
104
  * Send the same template to many recipients.
109
- * Each recipient can supply its own variables; shared variables are the fallback.
105
+ *
106
+ * Detailed batch report:
107
+ * - succeeded[]: index, to, data (the server's response JSON)
108
+ * - failed[]: index, to, error (typed ErrorLog), originalParams (full params preserved)
109
+ * - hitMaxLimit: true if the server signalled quota exhausted — stops sending immediately
110
+ *
111
+ * Failed recipients' originalParams can be passed straight to queue.enqueue()
112
+ * so nothing is lost.
110
113
  */
111
114
  async sendMany(recipients, template, sharedVariables = {}) {
112
115
  const result = {
@@ -115,23 +118,29 @@ export default class SMSClient {
115
118
  total: recipients.length,
116
119
  successCount: 0,
117
120
  failureCount: 0,
121
+ hitMaxLimit: false,
118
122
  };
119
123
  const chunks = chunkArray(recipients, this.batchSize);
120
- for (const chunk of chunks) {
124
+ outer: for (const chunk of chunks) {
125
+ // Shared flag: if one worker hits MAX_LIMIT, all workers stop
126
+ let maxLimitHit = false;
121
127
  const queue = [...chunk.entries()];
122
128
  await Promise.all(new Array(this.concurrency).fill(null).map(async () => {
123
129
  while (queue.length > 0) {
130
+ if (maxLimitHit)
131
+ break;
124
132
  const entry = queue.shift();
125
133
  if (!entry)
126
134
  break;
127
135
  const [chunkIndex, recipient] = entry;
128
136
  const globalIndex = chunks.indexOf(chunk) * this.batchSize + chunkIndex;
129
137
  const variables = { ...sharedVariables, ...recipient.variables };
130
- const res = await this.send({
138
+ const originalParams = {
131
139
  to: recipient.to,
132
140
  template,
133
141
  variables,
134
- });
142
+ };
143
+ const res = await this.send({ ...originalParams });
135
144
  if (res.ok) {
136
145
  result.succeeded.push({
137
146
  index: globalIndex,
@@ -141,15 +150,51 @@ export default class SMSClient {
141
150
  result.successCount++;
142
151
  }
143
152
  else {
144
- result.failed.push({
153
+ const failedItem = {
145
154
  index: globalIndex,
146
155
  to: recipient.to,
147
156
  error: res.error,
148
- });
157
+ originalParams,
158
+ };
159
+ result.failed.push(failedItem);
149
160
  result.failureCount++;
161
+ // Quota exhausted — stop sending the rest immediately
162
+ if (res.error.code === "MAX_LIMIT_ERROR") {
163
+ maxLimitHit = true;
164
+ result.hitMaxLimit = true;
165
+ }
150
166
  }
151
167
  }
152
168
  }));
169
+ // Also break the outer chunk loop if quota hit
170
+ if (result.hitMaxLimit) {
171
+ // Mark remaining recipients as failed with the quota error — data is NOT lost
172
+ const processedIndices = new Set([
173
+ ...result.succeeded.map((s) => s.index),
174
+ ...result.failed.map((f) => f.index),
175
+ ]);
176
+ for (let i = 0; i < recipients.length; i++) {
177
+ if (!processedIndices.has(i)) {
178
+ const r = recipients[i];
179
+ const variables = { ...sharedVariables, ...r.variables };
180
+ result.failed.push({
181
+ index: i,
182
+ to: r.to,
183
+ error: {
184
+ name: "MaxLimitError",
185
+ message: "Skipped — server quota was exhausted before this recipient",
186
+ code: "MAX_LIMIT_ERROR",
187
+ isClientError: false,
188
+ isServerError: true,
189
+ timestamp: new Date().toISOString(),
190
+ },
191
+ originalParams: { to: r.to, template, variables },
192
+ });
193
+ result.failureCount++;
194
+ }
195
+ }
196
+ break outer;
197
+ }
153
198
  }
154
199
  return result;
155
200
  }
@@ -172,9 +217,7 @@ export default class SMSClient {
172
217
  return fail(error);
173
218
  }
174
219
  }
175
- /**
176
- * Send object-derived messages to many recipients.
177
- */
220
+ /** Send object-derived messages to many recipients. */
178
221
  async sendObjectMany(items) {
179
222
  const result = {
180
223
  succeeded: [],
@@ -182,12 +225,16 @@ export default class SMSClient {
182
225
  total: items.length,
183
226
  successCount: 0,
184
227
  failureCount: 0,
228
+ hitMaxLimit: false,
185
229
  };
186
230
  const chunks = chunkArray(items, this.batchSize);
187
- for (const chunk of chunks) {
231
+ outer: for (const chunk of chunks) {
232
+ let maxLimitHit = false;
188
233
  const queue = [...chunk.entries()];
189
234
  await Promise.all(new Array(this.concurrency).fill(null).map(async () => {
190
235
  while (queue.length > 0) {
236
+ if (maxLimitHit)
237
+ break;
191
238
  const entry = queue.shift();
192
239
  if (!entry)
193
240
  break;
@@ -203,27 +250,37 @@ export default class SMSClient {
203
250
  result.successCount++;
204
251
  }
205
252
  else {
253
+ // For object sends, preserve what we can
254
+ const originalParams = {
255
+ to: item.to,
256
+ template: item.template ?? JSON.stringify(item.object),
257
+ variables: {},
258
+ };
206
259
  result.failed.push({
207
260
  index: globalIndex,
208
261
  to: item.to,
209
262
  error: res.error,
263
+ originalParams,
210
264
  });
211
265
  result.failureCount++;
266
+ if (res.error.code === "MAX_LIMIT_ERROR") {
267
+ maxLimitHit = true;
268
+ result.hitMaxLimit = true;
269
+ }
212
270
  }
213
271
  }
214
272
  }));
273
+ if (result.hitMaxLimit)
274
+ break outer;
215
275
  }
216
276
  return result;
217
277
  }
218
- /** Current circuit breaker state. */
219
278
  get circuitState() {
220
279
  return this.circuit.currentState;
221
280
  }
222
- /** Manually reset the circuit breaker (e.g. after fixing a downstream issue). */
223
281
  resetCircuit() {
224
282
  this.circuit.reset();
225
283
  }
226
- // ─────────────────────────────────────────────────────────────────────────
227
284
  validateOptions(options) {
228
285
  const { baseUrl, apiKey, projectKey } = options;
229
286
  if (!baseUrl)
@@ -1,6 +1,6 @@
1
1
  import type SMSClient from "./SMSClient.js";
2
2
  import type PluginManager from "./PluginManager.js";
3
- import type { SendParams } from "./types.js";
3
+ import type { SendParams, ErrorLog } from "./types.js";
4
4
  import type { SendMeta } from "./SendConfig.js";
5
5
  export interface QueueJob {
6
6
  id: string;
@@ -8,75 +8,105 @@ export interface QueueJob {
8
8
  meta: SendMeta["_meta"];
9
9
  scheduledAt: number;
10
10
  enqueuedAt: number;
11
+ /** How many times this job has been attempted (including the current one) */
11
12
  attempts: number;
13
+ /** Max retry attempts before giving up (default: 3) */
14
+ maxAttempts: number;
15
+ /** Full history of every attempt result for this job */
16
+ history: AttemptRecord[];
17
+ }
18
+ export interface AttemptRecord {
19
+ attempt: number;
20
+ timestamp: string;
21
+ ok: boolean;
22
+ durationMs: number;
23
+ error?: ErrorLog;
24
+ data?: unknown;
12
25
  }
13
26
  export interface QueueOptions {
14
- /** Max concurrent workers processing the queue (default: 3) */
27
+ /** Max concurrent workers (default: 3) */
15
28
  concurrency?: number;
16
- /** How often (ms) the queue polls for scheduled jobs (default: 1000) */
29
+ /** Poll interval ms (default: 1000) */
17
30
  pollInterval?: number;
18
- /** Whether to start processing automatically on first enqueue (default: true) */
31
+ /** Auto-start on first enqueue (default: true) */
19
32
  autoStart?: boolean;
33
+ /** Default max retry attempts per job (default: 3) */
34
+ maxAttempts?: number;
35
+ /** Base retry delay ms for failed jobs (default: 2000) */
36
+ retryDelay?: number;
20
37
  }
21
38
  export interface QueueStats {
22
39
  pending: number;
23
40
  running: number;
24
41
  completed: number;
42
+ failed: number;
25
43
  dropped: number;
26
44
  }
45
+ export interface FailedJob {
46
+ job: QueueJob;
47
+ finalError: ErrorLog;
48
+ /** All attempt records so nothing is lost */
49
+ history: AttemptRecord[];
50
+ }
27
51
  /**
28
- * SMSQueue — an in-process job queue for fire-and-forget or scheduled SMS.
52
+ * SMSQueue — priority job queue with full retry, failure history, and data preservation.
29
53
  *
30
- * Features:
31
- * - Priority lanes (high / normal / low)
32
- * - Deduplication via dedupKey
33
- * - TTL drops stale jobs before sending
34
- * - Scheduled sends (send at a future timestamp)
35
- * - Concurrency-limited workers
36
- * - Plugin hooks (onDrop, onSuccess, onError, onRetry)
54
+ * When a job fails:
55
+ * - If the error is retryable (network, server, rate-limit) re-queued with backoff
56
+ * - If the error is permanent (validation, quota exhausted) → moved to dead-letter list
57
+ * - The full attempt history is kept on every job so no data is ever silently lost
37
58
  *
38
59
  * @example
39
- * const queue = new SMSQueue(client, { concurrency: 5 });
60
+ * const queue = new SMSQueue(client, { concurrency: 5, maxAttempts: 4 });
40
61
  * queue.start();
62
+ * queue.on("failed", ({ job, finalError }) => saveToDb(job, finalError));
41
63
  *
42
- * const cfg = SendConfig.otp().dedupKey(`otp:${userId}`);
43
- * await queue.enqueue(cfg.for(phone, { code }));
44
- *
45
- * // Schedule for later
46
- * await queue.enqueueAt(cfg.for(phone, { code }), Date.now() + 60_000);
64
+ * queue.enqueue(otpConfig.for(phone, { code }));
47
65
  */
48
66
  export default class SMSQueue {
49
67
  private client;
50
68
  private jobs;
69
+ private deadLetters;
51
70
  private dedupSet;
71
+ private listeners;
52
72
  private running;
53
73
  private completed;
74
+ private failedCount;
54
75
  private dropped;
55
76
  private pollTimer?;
56
77
  private readonly concurrency;
57
78
  private readonly pollInterval;
58
79
  private readonly autoStart;
80
+ private readonly defaultMaxAttempts;
81
+ private readonly retryDelay;
59
82
  private plugins?;
60
83
  constructor(client: SMSClient, options?: QueueOptions, plugins?: PluginManager);
61
- /** Enqueue a job for immediate (or next-available) processing. */
62
- enqueue(params: SendParams & Partial<SendMeta>): string;
63
- /** Enqueue a job to send at a specific future timestamp (epoch ms). */
64
- enqueueAt(params: SendParams & Partial<SendMeta>, timestamp: number): string;
65
- /** Enqueue a job to send after a delay (ms). */
66
- enqueueAfter(params: SendParams & Partial<SendMeta>, delayMs: number): string;
84
+ on(event: "failed" | "completed" | "dropped", listener: (data: any) => void): this;
85
+ private emit;
86
+ enqueue(params: SendParams & Partial<SendMeta>, maxAttempts?: number): string;
87
+ enqueueAt(params: SendParams & Partial<SendMeta>, timestamp: number, maxAttempts?: number): string;
88
+ enqueueAfter(params: SendParams & Partial<SendMeta>, delayMs: number, maxAttempts?: number): string;
67
89
  private _enqueue;
68
90
  private _insertByPriority;
69
- /** Start the queue workers. */
91
+ /**
92
+ * Re-enqueue jobs that previously failed.
93
+ * Pass the array from `queue.getDeadLetters()` or `batchResult.failed` re-wrapped.
94
+ * Clears them from the dead-letter list.
95
+ */
96
+ requeueFailed(jobs: FailedJob[]): string[];
70
97
  start(): this;
71
- /** Drain the queue (process remaining jobs) then stop. */
72
98
  drain(): Promise<void>;
73
- /** Stop polling. In-flight jobs still complete. */
74
99
  stop(): this;
75
100
  private _tick;
76
101
  private _process;
77
102
  stats(): QueueStats;
78
- /** Cancel a job by id. Returns true if found and removed. */
103
+ /**
104
+ * Returns all jobs that exhausted their retries or hit a permanent error.
105
+ * Each entry includes the full attempt history — no data is lost.
106
+ */
107
+ getDeadLetters(): FailedJob[];
108
+ /** Clear the dead-letter list (e.g. after you've processed/saved them). */
109
+ clearDeadLetters(): FailedJob[];
79
110
  cancel(id: string): boolean;
80
- /** Clear all pending (not in-flight) jobs. */
81
111
  clear(): number;
82
112
  }