bridgex 2.0.0 → 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.
package/src/SMSClient.ts CHANGED
@@ -13,6 +13,7 @@ import type {
13
13
  SendParams,
14
14
  SendObjectParams,
15
15
  BatchResult,
16
+ FailedItem,
16
17
  } from "./types.js";
17
18
 
18
19
  export interface SMSClientOptions {
@@ -88,14 +89,13 @@ export default class SMSClient {
88
89
  options.plugins?.forEach((p) => this.plugins.use(p));
89
90
  }
90
91
 
91
- /** Attach a plugin at any time. */
92
92
  use(plugin: Plugin): this {
93
93
  this.plugins.use(plugin);
94
94
  return this;
95
95
  }
96
96
 
97
97
  // ─────────────────────────────────────────────────────────────────────────
98
- // Core internal send — all public methods funnel through here
98
+ // Core internal send
99
99
  // ─────────────────────────────────────────────────────────────────────────
100
100
 
101
101
  private async _send(
@@ -134,10 +134,7 @@ export default class SMSClient {
134
134
  // Public API
135
135
  // ─────────────────────────────────────────────────────────────────────────
136
136
 
137
- /**
138
- * Send a single SMS message.
139
- * Returns a Result — never throws.
140
- */
137
+ /** Send a single SMS. Returns a Result — never throws. */
141
138
  async send(params: SendParams & { _meta?: any }): Promise<Result<any>> {
142
139
  const { to, template, variables = {} } = params;
143
140
  const tags = params._meta?.tags ?? [];
@@ -154,7 +151,14 @@ export default class SMSClient {
154
151
 
155
152
  /**
156
153
  * Send the same template to many recipients.
157
- * Each recipient can supply its own variables; shared variables are the fallback.
154
+ *
155
+ * Detailed batch report:
156
+ * - succeeded[]: index, to, data (the server's response JSON)
157
+ * - failed[]: index, to, error (typed ErrorLog), originalParams (full params preserved)
158
+ * - hitMaxLimit: true if the server signalled quota exhausted — stops sending immediately
159
+ *
160
+ * Failed recipients' originalParams can be passed straight to queue.enqueue()
161
+ * so nothing is lost.
158
162
  */
159
163
  async sendMany(
160
164
  recipients: Array<{ to: string; variables?: Record<string, unknown> }>,
@@ -167,16 +171,22 @@ export default class SMSClient {
167
171
  total: recipients.length,
168
172
  successCount: 0,
169
173
  failureCount: 0,
174
+ hitMaxLimit: false,
170
175
  };
171
176
 
172
177
  const chunks = chunkArray(recipients, this.batchSize);
173
178
 
174
- for (const chunk of chunks) {
179
+ outer: for (const chunk of chunks) {
180
+ // Shared flag: if one worker hits MAX_LIMIT, all workers stop
181
+ let maxLimitHit = false;
182
+
175
183
  const queue = [...chunk.entries()];
176
184
 
177
185
  await Promise.all(
178
186
  new Array(this.concurrency).fill(null).map(async () => {
179
187
  while (queue.length > 0) {
188
+ if (maxLimitHit) break;
189
+
180
190
  const entry = queue.shift();
181
191
  if (!entry) break;
182
192
 
@@ -184,11 +194,13 @@ export default class SMSClient {
184
194
  const globalIndex =
185
195
  chunks.indexOf(chunk) * this.batchSize + chunkIndex;
186
196
  const variables = { ...sharedVariables, ...recipient.variables };
187
- const res = await this.send({
197
+ const originalParams: SendParams = {
188
198
  to: recipient.to,
189
199
  template,
190
200
  variables,
191
- });
201
+ };
202
+
203
+ const res = await this.send({ ...originalParams });
192
204
 
193
205
  if (res.ok) {
194
206
  result.succeeded.push({
@@ -198,16 +210,56 @@ export default class SMSClient {
198
210
  });
199
211
  result.successCount++;
200
212
  } else {
201
- result.failed.push({
213
+ const failedItem: FailedItem = {
202
214
  index: globalIndex,
203
215
  to: recipient.to,
204
216
  error: res.error,
205
- });
217
+ originalParams,
218
+ };
219
+ result.failed.push(failedItem);
206
220
  result.failureCount++;
221
+
222
+ // Quota exhausted — stop sending the rest immediately
223
+ if (res.error.code === "MAX_LIMIT_ERROR") {
224
+ maxLimitHit = true;
225
+ result.hitMaxLimit = true;
226
+ }
207
227
  }
208
228
  }
209
229
  }),
210
230
  );
231
+
232
+ // Also break the outer chunk loop if quota hit
233
+ if (result.hitMaxLimit) {
234
+ // Mark remaining recipients as failed with the quota error — data is NOT lost
235
+ const processedIndices = new Set([
236
+ ...result.succeeded.map((s) => s.index),
237
+ ...result.failed.map((f) => f.index),
238
+ ]);
239
+
240
+ for (let i = 0; i < recipients.length; i++) {
241
+ if (!processedIndices.has(i)) {
242
+ const r = recipients[i];
243
+ const variables = { ...sharedVariables, ...r.variables };
244
+ result.failed.push({
245
+ index: i,
246
+ to: r.to,
247
+ error: {
248
+ name: "MaxLimitError",
249
+ message:
250
+ "Skipped — server quota was exhausted before this recipient",
251
+ code: "MAX_LIMIT_ERROR",
252
+ isClientError: false,
253
+ isServerError: true,
254
+ timestamp: new Date().toISOString(),
255
+ },
256
+ originalParams: { to: r.to, template, variables },
257
+ });
258
+ result.failureCount++;
259
+ }
260
+ }
261
+ break outer;
262
+ }
211
263
  }
212
264
 
213
265
  return result;
@@ -234,9 +286,7 @@ export default class SMSClient {
234
286
  }
235
287
  }
236
288
 
237
- /**
238
- * Send object-derived messages to many recipients.
239
- */
289
+ /** Send object-derived messages to many recipients. */
240
290
  async sendObjectMany<T extends Record<string, unknown>>(
241
291
  items: Array<SendObjectParams<T> & { _meta?: any }>,
242
292
  ): Promise<BatchResult<any>> {
@@ -246,16 +296,20 @@ export default class SMSClient {
246
296
  total: items.length,
247
297
  successCount: 0,
248
298
  failureCount: 0,
299
+ hitMaxLimit: false,
249
300
  };
250
301
 
251
302
  const chunks = chunkArray(items, this.batchSize);
252
303
 
253
- for (const chunk of chunks) {
304
+ outer: for (const chunk of chunks) {
305
+ let maxLimitHit = false;
254
306
  const queue = [...chunk.entries()];
255
307
 
256
308
  await Promise.all(
257
309
  new Array(this.concurrency).fill(null).map(async () => {
258
310
  while (queue.length > 0) {
311
+ if (maxLimitHit) break;
312
+
259
313
  const entry = queue.shift();
260
314
  if (!entry) break;
261
315
 
@@ -272,33 +326,43 @@ export default class SMSClient {
272
326
  });
273
327
  result.successCount++;
274
328
  } else {
329
+ // For object sends, preserve what we can
330
+ const originalParams: SendParams = {
331
+ to: item.to,
332
+ template: item.template ?? JSON.stringify(item.object),
333
+ variables: {},
334
+ };
275
335
  result.failed.push({
276
336
  index: globalIndex,
277
337
  to: item.to,
278
338
  error: res.error,
339
+ originalParams,
279
340
  });
280
341
  result.failureCount++;
342
+
343
+ if (res.error.code === "MAX_LIMIT_ERROR") {
344
+ maxLimitHit = true;
345
+ result.hitMaxLimit = true;
346
+ }
281
347
  }
282
348
  }
283
349
  }),
284
350
  );
351
+
352
+ if (result.hitMaxLimit) break outer;
285
353
  }
286
354
 
287
355
  return result;
288
356
  }
289
357
 
290
- /** Current circuit breaker state. */
291
358
  get circuitState() {
292
359
  return this.circuit.currentState;
293
360
  }
294
361
 
295
- /** Manually reset the circuit breaker (e.g. after fixing a downstream issue). */
296
362
  resetCircuit() {
297
363
  this.circuit.reset();
298
364
  }
299
365
 
300
- // ─────────────────────────────────────────────────────────────────────────
301
-
302
366
  private validateOptions(options: SMSClientOptions): void {
303
367
  const { baseUrl, apiKey, projectKey } = options;
304
368
  if (!baseUrl) throw new ValidationError("baseUrl is required");
package/src/SMSQueue.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type SMSClient from "./SMSClient.js";
2
2
  import type PluginManager from "./PluginManager.js";
3
- import type { SendParams, Result } from "./types.js";
3
+ import type { SendParams, Result, ErrorLog } from "./types.js";
4
4
  import type { SendMeta } from "./SendConfig.js";
5
5
 
6
6
  type Priority = "low" | "normal" | "high";
@@ -10,62 +10,96 @@ const PRIORITY_WEIGHT: Record<Priority, number> = {
10
10
  low: 1,
11
11
  };
12
12
 
13
+ /** Error codes that are permanent — re-queuing won't help. */
14
+ const NON_RETRYABLE_CODES = new Set([
15
+ "VALIDATION_ERROR",
16
+ "TEMPLATE_ERROR",
17
+ "MAX_LIMIT_ERROR", // quota exhausted — no point retrying until quota resets
18
+ ]);
19
+
13
20
  export interface QueueJob {
14
21
  id: string;
15
22
  params: SendParams;
16
23
  meta: SendMeta["_meta"];
17
- scheduledAt: number; // epoch ms — 0 = immediately
24
+ scheduledAt: number; // epoch ms — 0 means "run ASAP"
18
25
  enqueuedAt: number;
26
+ /** How many times this job has been attempted (including the current one) */
19
27
  attempts: number;
28
+ /** Max retry attempts before giving up (default: 3) */
29
+ maxAttempts: number;
30
+ /** Full history of every attempt result for this job */
31
+ history: AttemptRecord[];
32
+ }
33
+
34
+ export interface AttemptRecord {
35
+ attempt: number;
36
+ timestamp: string; // ISO 8601
37
+ ok: boolean;
38
+ durationMs: number;
39
+ error?: ErrorLog;
40
+ data?: unknown;
20
41
  }
21
42
 
22
43
  export interface QueueOptions {
23
- /** Max concurrent workers processing the queue (default: 3) */
44
+ /** Max concurrent workers (default: 3) */
24
45
  concurrency?: number;
25
- /** How often (ms) the queue polls for scheduled jobs (default: 1000) */
46
+ /** Poll interval ms (default: 1000) */
26
47
  pollInterval?: number;
27
- /** Whether to start processing automatically on first enqueue (default: true) */
48
+ /** Auto-start on first enqueue (default: true) */
28
49
  autoStart?: boolean;
50
+ /** Default max retry attempts per job (default: 3) */
51
+ maxAttempts?: number;
52
+ /** Base retry delay ms for failed jobs (default: 2000) */
53
+ retryDelay?: number;
29
54
  }
30
55
 
31
56
  export interface QueueStats {
32
57
  pending: number;
33
58
  running: number;
34
59
  completed: number;
60
+ failed: number;
35
61
  dropped: number;
36
62
  }
37
63
 
64
+ export interface FailedJob {
65
+ job: QueueJob;
66
+ finalError: ErrorLog;
67
+ /** All attempt records so nothing is lost */
68
+ history: AttemptRecord[];
69
+ }
70
+
38
71
  /**
39
- * SMSQueue — an in-process job queue for fire-and-forget or scheduled SMS.
72
+ * SMSQueue — priority job queue with full retry, failure history, and data preservation.
40
73
  *
41
- * Features:
42
- * - Priority lanes (high / normal / low)
43
- * - Deduplication via dedupKey
44
- * - TTL drops stale jobs before sending
45
- * - Scheduled sends (send at a future timestamp)
46
- * - Concurrency-limited workers
47
- * - Plugin hooks (onDrop, onSuccess, onError, onRetry)
74
+ * When a job fails:
75
+ * - If the error is retryable (network, server, rate-limit) re-queued with backoff
76
+ * - If the error is permanent (validation, quota exhausted) → moved to dead-letter list
77
+ * - The full attempt history is kept on every job so no data is ever silently lost
48
78
  *
49
79
  * @example
50
- * const queue = new SMSQueue(client, { concurrency: 5 });
80
+ * const queue = new SMSQueue(client, { concurrency: 5, maxAttempts: 4 });
51
81
  * queue.start();
82
+ * queue.on("failed", ({ job, finalError }) => saveToDb(job, finalError));
52
83
  *
53
- * const cfg = SendConfig.otp().dedupKey(`otp:${userId}`);
54
- * await queue.enqueue(cfg.for(phone, { code }));
55
- *
56
- * // Schedule for later
57
- * await queue.enqueueAt(cfg.for(phone, { code }), Date.now() + 60_000);
84
+ * queue.enqueue(otpConfig.for(phone, { code }));
58
85
  */
59
86
  export default class SMSQueue {
60
87
  private jobs: QueueJob[] = [];
88
+ private deadLetters: FailedJob[] = [];
61
89
  private dedupSet = new Set<string>();
90
+ private listeners: Map<string, Array<(data: any) => void>> = new Map();
91
+
62
92
  private running = 0;
63
93
  private completed = 0;
94
+ private failedCount = 0;
64
95
  private dropped = 0;
65
96
  private pollTimer?: ReturnType<typeof setInterval>;
97
+
66
98
  private readonly concurrency: number;
67
99
  private readonly pollInterval: number;
68
100
  private readonly autoStart: boolean;
101
+ private readonly defaultMaxAttempts: number;
102
+ private readonly retryDelay: number;
69
103
  private plugins?: PluginManager;
70
104
 
71
105
  constructor(
@@ -76,32 +110,61 @@ export default class SMSQueue {
76
110
  this.concurrency = options.concurrency ?? 3;
77
111
  this.pollInterval = options.pollInterval ?? 1000;
78
112
  this.autoStart = options.autoStart ?? true;
113
+ this.defaultMaxAttempts = options.maxAttempts ?? 3;
114
+ this.retryDelay = options.retryDelay ?? 2000;
79
115
  this.plugins = plugins;
80
116
  }
81
117
 
118
+ // ── Event emitter (tiny, no deps) ────────────────────────────────────────
119
+
120
+ on(
121
+ event: "failed" | "completed" | "dropped",
122
+ listener: (data: any) => void,
123
+ ): this {
124
+ if (!this.listeners.has(event)) this.listeners.set(event, []);
125
+ this.listeners.get(event)!.push(listener);
126
+ return this;
127
+ }
128
+
129
+ private emit(event: string, data: unknown) {
130
+ this.listeners.get(event)?.forEach((fn) => {
131
+ try {
132
+ fn(data);
133
+ } catch {
134
+ /* listener errors must not crash the queue */
135
+ }
136
+ });
137
+ }
138
+
82
139
  // ── Enqueue ──────────────────────────────────────────────────────────────
83
140
 
84
- /** Enqueue a job for immediate (or next-available) processing. */
85
- enqueue(params: SendParams & Partial<SendMeta>): string {
86
- return this._enqueue(params, 0);
141
+ enqueue(
142
+ params: SendParams & Partial<SendMeta>,
143
+ maxAttempts?: number,
144
+ ): string {
145
+ return this._enqueue(params, 0, maxAttempts);
87
146
  }
88
147
 
89
- /** Enqueue a job to send at a specific future timestamp (epoch ms). */
90
- enqueueAt(params: SendParams & Partial<SendMeta>, timestamp: number): string {
91
- return this._enqueue(params, timestamp);
148
+ enqueueAt(
149
+ params: SendParams & Partial<SendMeta>,
150
+ timestamp: number,
151
+ maxAttempts?: number,
152
+ ): string {
153
+ return this._enqueue(params, timestamp, maxAttempts);
92
154
  }
93
155
 
94
- /** Enqueue a job to send after a delay (ms). */
95
156
  enqueueAfter(
96
157
  params: SendParams & Partial<SendMeta>,
97
158
  delayMs: number,
159
+ maxAttempts?: number,
98
160
  ): string {
99
- return this._enqueue(params, Date.now() + delayMs);
161
+ return this._enqueue(params, Date.now() + delayMs, maxAttempts);
100
162
  }
101
163
 
102
164
  private _enqueue(
103
165
  params: SendParams & Partial<SendMeta>,
104
166
  scheduledAt: number,
167
+ maxAttempts?: number,
105
168
  ): string {
106
169
  const meta = (params as any)._meta ?? {
107
170
  tags: [],
@@ -118,7 +181,8 @@ export default class SMSQueue {
118
181
  tags: meta.tags,
119
182
  reason: "dedup",
120
183
  });
121
- return meta.dedupKey; // return key so caller can identify it
184
+ this.emit("dropped", { reason: "dedup", params });
185
+ return meta.dedupKey;
122
186
  }
123
187
  this.dedupSet.add(meta.dedupKey);
124
188
  }
@@ -134,20 +198,19 @@ export default class SMSQueue {
134
198
  scheduledAt,
135
199
  enqueuedAt: Date.now(),
136
200
  attempts: 0,
201
+ maxAttempts: maxAttempts ?? this.defaultMaxAttempts,
202
+ history: [],
137
203
  };
138
204
 
139
205
  this._insertByPriority(job);
140
206
 
141
- if (this.autoStart && !this.pollTimer) {
142
- this.start();
143
- }
207
+ if (this.autoStart && !this.pollTimer) this.start();
144
208
 
145
209
  return id;
146
210
  }
147
211
 
148
212
  private _insertByPriority(job: QueueJob) {
149
213
  const w = PRIORITY_WEIGHT[job.meta.priority as Priority] ?? 2;
150
- // Find insertion point: higher weight jobs go first
151
214
  let i = 0;
152
215
  while (
153
216
  i < this.jobs.length &&
@@ -157,17 +220,32 @@ export default class SMSQueue {
157
220
  this.jobs.splice(i, 0, job);
158
221
  }
159
222
 
160
- // ── Lifecycle ────────────────────────────────────────────────────────────
223
+ // ── Re-enqueue (used after failure) ──────────────────────────────────────
224
+
225
+ /**
226
+ * Re-enqueue jobs that previously failed.
227
+ * Pass the array from `queue.getDeadLetters()` or `batchResult.failed` re-wrapped.
228
+ * Clears them from the dead-letter list.
229
+ */
230
+ requeueFailed(jobs: FailedJob[]): string[] {
231
+ return jobs.map(({ job }) => {
232
+ // Remove from dead-letter list
233
+ this.deadLetters = this.deadLetters.filter((d) => d.job.id !== job.id);
234
+ // Re-insert with attempt history preserved
235
+ this._insertByPriority(job);
236
+ return job.id;
237
+ });
238
+ }
239
+
240
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
161
241
 
162
- /** Start the queue workers. */
163
242
  start(): this {
164
243
  if (this.pollTimer) return this;
165
244
  this.pollTimer = setInterval(() => this._tick(), this.pollInterval);
166
- this._tick(); // immediate first tick
245
+ this._tick();
167
246
  return this;
168
247
  }
169
248
 
170
- /** Drain the queue (process remaining jobs) then stop. */
171
249
  async drain(): Promise<void> {
172
250
  this.stop();
173
251
  while (this.jobs.length > 0 || this.running > 0) {
@@ -176,7 +254,6 @@ export default class SMSQueue {
176
254
  }
177
255
  }
178
256
 
179
- /** Stop polling. In-flight jobs still complete. */
180
257
  stop(): this {
181
258
  if (this.pollTimer) {
182
259
  clearInterval(this.pollTimer);
@@ -185,16 +262,13 @@ export default class SMSQueue {
185
262
  return this;
186
263
  }
187
264
 
188
- // ── Processing ───────────────────────────────────────────────────────────
265
+ // ── Processing ────────────────────────────────────────────────────────────
189
266
 
190
267
  private _tick() {
191
268
  const now = Date.now();
192
-
193
269
  while (this.running < this.concurrency && this.jobs.length > 0) {
194
- // Find next ready job (scheduledAt <= now)
195
270
  const idx = this.jobs.findIndex((j) => j.scheduledAt <= now);
196
271
  if (idx === -1) break;
197
-
198
272
  const [job] = this.jobs.splice(idx, 1);
199
273
  this._process(job);
200
274
  }
@@ -213,10 +287,12 @@ export default class SMSQueue {
213
287
  tags: meta.tags,
214
288
  reason: "ttl",
215
289
  });
290
+ this.emit("dropped", { reason: "ttl", job });
216
291
  return;
217
292
  }
218
293
 
219
294
  this.running++;
295
+ job.attempts++;
220
296
  const start = Date.now();
221
297
  await this.plugins?.fire("onSend", {
222
298
  to: params.to,
@@ -225,11 +301,21 @@ export default class SMSQueue {
225
301
  });
226
302
 
227
303
  const result = (await (this.client as any).send(params)) as Result<any>;
228
-
229
304
  this.running--;
230
305
  const durationMs = Date.now() - start;
231
306
 
307
+ // Record this attempt
308
+ const record: AttemptRecord = {
309
+ attempt: job.attempts,
310
+ timestamp: new Date().toISOString(),
311
+ ok: result.ok,
312
+ durationMs,
313
+ ...(result.ok ? { data: result.data } : { error: result.error }),
314
+ };
315
+ job.history.push(record);
316
+
232
317
  if (result.ok) {
318
+ // ── SUCCESS ──────────────────────────────────────────────────────────
233
319
  this.completed++;
234
320
  if (meta.dedupKey) this.dedupSet.delete(meta.dedupKey);
235
321
  await this.plugins?.fire("onSuccess", {
@@ -238,29 +324,76 @@ export default class SMSQueue {
238
324
  data: result.data,
239
325
  durationMs,
240
326
  });
327
+ this.emit("completed", { job, data: result.data });
241
328
  } else {
242
- if (meta.dedupKey) this.dedupSet.delete(meta.dedupKey);
329
+ // ── FAILURE ──────────────────────────────────────────────────────────
330
+ const isPermanent = NON_RETRYABLE_CODES.has(result.error.code);
331
+ const retriesExhausted = job.attempts >= job.maxAttempts;
332
+
243
333
  await this.plugins?.fire("onError", {
244
334
  to: params.to,
245
335
  tags: meta.tags,
246
336
  error: result.error,
247
337
  durationMs,
338
+ attempt: job.attempts,
248
339
  });
340
+
341
+ if (!isPermanent && !retriesExhausted) {
342
+ // ── RE-QUEUE with exponential backoff ─────────────────────────────
343
+ const backoff = this.retryDelay * 2 ** (job.attempts - 1);
344
+ job.scheduledAt = Date.now() + backoff;
345
+ this._insertByPriority(job); // put back in queue
346
+
347
+ await this.plugins?.fire("onRetry", {
348
+ to: params.to,
349
+ tags: meta.tags,
350
+ error: result.error,
351
+ attempt: job.attempts,
352
+ });
353
+ } else {
354
+ // ── DEAD LETTER — data is preserved, never silently lost ──────────
355
+ this.failedCount++;
356
+ if (meta.dedupKey) this.dedupSet.delete(meta.dedupKey);
357
+
358
+ const failedJob: FailedJob = {
359
+ job,
360
+ finalError: result.error,
361
+ history: job.history,
362
+ };
363
+
364
+ this.deadLetters.push(failedJob);
365
+ this.emit("failed", failedJob);
366
+ }
249
367
  }
250
368
  }
251
369
 
252
- // ── Stats ────────────────────────────────────────────────────────────────
370
+ // ── Inspection ────────────────────────────────────────────────────────────
253
371
 
254
372
  stats(): QueueStats {
255
373
  return {
256
374
  pending: this.jobs.length,
257
375
  running: this.running,
258
376
  completed: this.completed,
377
+ failed: this.failedCount,
259
378
  dropped: this.dropped,
260
379
  };
261
380
  }
262
381
 
263
- /** Cancel a job by id. Returns true if found and removed. */
382
+ /**
383
+ * Returns all jobs that exhausted their retries or hit a permanent error.
384
+ * Each entry includes the full attempt history — no data is lost.
385
+ */
386
+ getDeadLetters(): FailedJob[] {
387
+ return [...this.deadLetters];
388
+ }
389
+
390
+ /** Clear the dead-letter list (e.g. after you've processed/saved them). */
391
+ clearDeadLetters(): FailedJob[] {
392
+ const copy = [...this.deadLetters];
393
+ this.deadLetters = [];
394
+ return copy;
395
+ }
396
+
264
397
  cancel(id: string): boolean {
265
398
  const idx = this.jobs.findIndex((j) => j.id === id);
266
399
  if (idx === -1) return false;
@@ -269,7 +402,6 @@ export default class SMSQueue {
269
402
  return true;
270
403
  }
271
404
 
272
- /** Clear all pending (not in-flight) jobs. */
273
405
  clear(): number {
274
406
  const count = this.jobs.length;
275
407
  this.jobs.forEach(
package/src/errors.ts CHANGED
@@ -69,3 +69,9 @@ export class CircuitOpenError extends SMSClientError {
69
69
  super(message, "CIRCUIT_OPEN", details, false, false);
70
70
  }
71
71
  }
72
+
73
+ export class MaxLimitError extends SMSClientError {
74
+ constructor(message: string, details?: unknown) {
75
+ super(message, "MAX_LIMIT_ERROR", details, false, true);
76
+ }
77
+ }