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/dist/CircuitBreaker.d.ts +25 -0
- package/dist/CircuitBreaker.js +58 -0
- package/dist/CredentialProvisioner.d.ts +27 -0
- package/dist/CredentialProvisioner.js +43 -0
- package/dist/HttpClient.js +30 -3
- package/dist/MessageFormatter.d.ts +13 -1
- package/dist/MessageFormatter.js +30 -6
- package/dist/PluginManager.d.ts +77 -0
- package/dist/PluginManager.js +101 -0
- package/dist/RetryHandler.d.ts +8 -1
- package/dist/RetryHandler.js +28 -6
- package/dist/SMSClient.d.ts +64 -0
- package/dist/SMSClient.js +293 -0
- package/dist/SMSQueue.d.ts +112 -0
- package/dist/SMSQueue.js +290 -0
- package/dist/SMSScheduler.d.ts +75 -0
- package/dist/SMSScheduler.js +196 -0
- package/dist/SMSService.d.ts +41 -0
- package/dist/SMSService.js +214 -0
- package/dist/SendConfig.d.ts +90 -0
- package/dist/SendConfig.js +165 -0
- package/dist/errors.d.ts +21 -1
- package/dist/errors.js +33 -5
- package/dist/helpers.d.ts +3 -0
- package/dist/helpers.js +23 -0
- package/dist/main.d.ts +20 -1
- package/dist/main.js +19 -1
- package/dist/types.d.ts +50 -0
- package/dist/types.js +1 -0
- package/package.json +1 -1
- package/src/HttpClient.ts +49 -5
- package/src/SMSClient.ts +84 -20
- package/src/SMSQueue.ts +178 -46
- package/src/errors.ts +6 -0
- package/src/main.ts +13 -4
- package/src/types.ts +19 -2
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
44
|
+
/** Max concurrent workers (default: 3) */
|
|
24
45
|
concurrency?: number;
|
|
25
|
-
/**
|
|
46
|
+
/** Poll interval ms (default: 1000) */
|
|
26
47
|
pollInterval?: number;
|
|
27
|
-
/**
|
|
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 —
|
|
72
|
+
* SMSQueue — priority job queue with full retry, failure history, and data preservation.
|
|
40
73
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
141
|
+
enqueue(
|
|
142
|
+
params: SendParams & Partial<SendMeta>,
|
|
143
|
+
maxAttempts?: number,
|
|
144
|
+
): string {
|
|
145
|
+
return this._enqueue(params, 0, maxAttempts);
|
|
87
146
|
}
|
|
88
147
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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();
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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
|
-
/**
|
|
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
|
+
}
|