@fgiova/sqs-consumer 3.2.0 → 3.3.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
@@ -78,13 +78,32 @@ const consumer = new SQSConsumer({
78
78
  | destroySigner | boolean | true | Whether to destroy the signer when the consumer is destroyed. |
79
79
 
80
80
  ### ConsumerOptions
81
- | Option | Type | Default | Description |
82
- |-----------------------|----------|---------|------------------------------------------------------------|
83
- | visibilityTimeout | number | 30 | The visibility timeout for the messages in seconds |
84
- | waitTimeSeconds | number | 20 | The wait time for the receiveMessage call in seconds |
85
- | itemsPerRequest | number | 10 | The maximum number of messages to be received at once |
86
- | messageAttributeNames | string[] | [] | The message attribute names to be included in the response |
87
- | attributeNames | string[] | [] | The attribute names to be included in the response |
81
+ | Option | Type | Default | Description |
82
+ |-----------------------|-----------------------------|---------|----------------------------------------------------------------------------------------------------------------------------------------------|
83
+ | visibilityTimeout | number | 30 | The visibility timeout for the messages in seconds |
84
+ | waitTimeSeconds | number | 20 | The wait time for the receiveMessage call in seconds |
85
+ | itemsPerRequest | number | 10 | The maximum number of messages to be received at once |
86
+ | messageAttributeNames | string[] | [] | The message attribute names to be included in the response |
87
+ | attributeNames | string[] | [] | The attribute names to be included in the response |
88
+ | errorDelaySeconds | number | 0 | Seconds a failed message stays invisible before it can be received again |
89
+ | errorDelayBackoff | (attempt: number) => number | | Returns the delay in seconds for a failed message, given its `ApproximateReceiveCount`. Takes precedence over `errorDelaySeconds` |
90
+
91
+ Delays are truncated to an integer and clamped to the SQS range 0–43200 seconds. When `errorDelayBackoff` is set, `ApproximateReceiveCount` is added to `attributeNames` automatically. If `errorDelayBackoff` throws, the error is reported to `onSQSError` and `errorDelaySeconds` is used instead.
92
+
93
+ > **Note:** SQS counts the 12-hour visibility limit from when the message was received, not from when the delay is set. A delay that exceeds the remaining time (roughly `43200 - executionTimeout - visibility extensions`) is silently rejected by SQS and the message is retried after its current visibility timeout. Keep delays well below 12 hours, and keep their sum below the queue's `MessageRetentionPeriod`, or the message expires without reaching the DLQ.
94
+ >
95
+ > Every retry increments `ApproximateReceiveCount`, so with a redrive policy the message moves to the DLQ after `maxReceiveCount` attempts: size the backoff accordingly. On FIFO queues a delayed message blocks its whole message group until the delay expires.
96
+
97
+ ```typescript
98
+ const consumer = new SQSConsumer({
99
+ queueARN: "arn:aws:sqs:eu-central-1:000000000000:test",
100
+ handler: async (message) => { /* ... */ },
101
+ consumerOptions: {
102
+ // 1st failure: 2s, 2nd: 4s, 3rd: 8s ... capped at 15 minutes
103
+ errorDelayBackoff: (attempt) => Math.min(2 ** attempt, 900),
104
+ },
105
+ });
106
+ ```
88
107
 
89
108
  ### Hooks
90
109
  | Option | Type | Description |
@@ -23,6 +23,8 @@ type ConsumerOptions = {
23
23
  itemsPerRequest?: number;
24
24
  messageAttributeNames?: string[];
25
25
  attributeNames?: string[];
26
+ errorDelaySeconds?: number;
27
+ errorDelayBackoff?: (attempt: number) => number;
26
28
  };
27
29
  type ConsumerHookName = Exclude<HookName, "onStart" | "onStop">;
28
30
  export type HooksOptions = {
@@ -46,9 +48,8 @@ export declare class SQSConsumer {
46
48
  private readonly sqsClient;
47
49
  private readonly hooks;
48
50
  private readonly messageHandler;
49
- private messagesOnFly;
50
51
  private running;
51
- private polling;
52
+ private pollLoop;
52
53
  private destroyed;
53
54
  private readonly logger;
54
55
  constructor(options: SQSConsumerOptions);
package/dist/cjs/index.js CHANGED
@@ -17,9 +17,8 @@ var context_2 = require("./context");
17
17
  Object.defineProperty(exports, "getAbortSignal", { enumerable: true, get: function () { return context_2.getAbortSignal; } });
18
18
  class SQSConsumer {
19
19
  constructor(options) {
20
- this.messagesOnFly = 0;
21
20
  this.running = false;
22
- this.polling = false;
21
+ this.pollLoop = null;
23
22
  this.destroyed = false;
24
23
  if (!options.queueARN)
25
24
  throw new Error("queueARN is required");
@@ -43,7 +42,16 @@ class SQSConsumer {
43
42
  waitTimeSeconds: options.consumerOptions?.waitTimeSeconds ?? 20,
44
43
  itemsPerRequest: options.consumerOptions?.itemsPerRequest ?? 10,
45
44
  messageAttributeNames: options.consumerOptions?.messageAttributeNames ?? [],
46
- attributeNames: options.consumerOptions?.attributeNames ?? [],
45
+ attributeNames: [
46
+ ...new Set([
47
+ ...(options.consumerOptions?.attributeNames ?? []),
48
+ ...(options.consumerOptions?.errorDelayBackoff
49
+ ? ["ApproximateReceiveCount"]
50
+ : []),
51
+ ]),
52
+ ],
53
+ errorDelaySeconds: options.consumerOptions?.errorDelaySeconds ?? 0,
54
+ errorDelayBackoff: options.consumerOptions?.errorDelayBackoff,
47
55
  };
48
56
  this.sqsClient =
49
57
  options.clientOptions?.sqsClient ??
@@ -96,7 +104,7 @@ class SQSConsumer {
96
104
  const err = new errors_1.TimeoutError("Handler execution timed out");
97
105
  abortController.abort(err);
98
106
  reject(err);
99
- }, this.handlerOptions.executionTimeout);
107
+ }, this.handlerOptions.executionTimeout).unref();
100
108
  }),
101
109
  ]);
102
110
  }
@@ -120,19 +128,19 @@ class SQSConsumer {
120
128
  clearTimeout(timeoutId);
121
129
  }
122
130
  }
123
- haExtendVisibilityTimeout(messages, visibilityTimeout) {
124
- if (this.handlerOptions.extendVisibilityTimeout !== false) {
125
- const processingMessages = messages.map(
126
- // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
127
- (message) => message.ReceiptHandle);
131
+ haExtendVisibilityTimeout(pendingReceipts, visibilityTimeout) {
132
+ if (this.handlerOptions.extendVisibilityTimeout && visibilityTimeout > 0) {
128
133
  return setInterval(async () => {
134
+ /* c8 ignore next 1 */
135
+ if (!pendingReceipts.size)
136
+ return;
129
137
  try {
130
- await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, processingMessages, visibilityTimeout);
138
+ await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, [...pendingReceipts], visibilityTimeout);
131
139
  } /* c8 ignore next 3 */
132
140
  catch (e) {
133
141
  await this.hooks.runHook("onSQSError", e);
134
142
  }
135
- }, visibilityTimeout * 1000 - 5);
143
+ }, visibilityTimeout * 500);
136
144
  }
137
145
  return null;
138
146
  }
@@ -141,10 +149,24 @@ class SQSConsumer {
141
149
  const candidateToRelease = [];
142
150
  for (const result of messagesResults) {
143
151
  if (result.error) {
144
- // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
145
- candidateToRelease.push(result.message.ReceiptHandle);
152
+ let delaySeconds = this.consumerOptions.errorDelaySeconds;
153
+ if (this.consumerOptions.errorDelayBackoff) {
154
+ try {
155
+ delaySeconds = this.consumerOptions.errorDelayBackoff(
156
+ /* c8 ignore next 1 */
157
+ Number(result.message.Attributes?.ApproximateReceiveCount ?? 0));
158
+ }
159
+ catch (e) {
160
+ await this.hooks.runHook("onSQSError", e);
161
+ }
162
+ }
163
+ candidateToRelease.push({
164
+ // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
165
+ receiptHandle: result.message.ReceiptHandle,
166
+ visibilityTimeout: Math.min(43200, Math.max(0, Math.trunc(delaySeconds) || 0)),
167
+ });
146
168
  }
147
- else if (this.handlerOptions.deleteMessage !== false) {
169
+ else if (this.handlerOptions.deleteMessage) {
148
170
  // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
149
171
  candidatesToDelete.push(result.message.ReceiptHandle);
150
172
  }
@@ -153,16 +175,14 @@ class SQSConsumer {
153
175
  await this.sqsClient.deleteMessageBatch(this.queueARN, candidatesToDelete);
154
176
  }
155
177
  if (candidateToRelease.length) {
156
- await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, candidateToRelease, 0);
178
+ await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, candidateToRelease);
157
179
  }
158
180
  }
159
181
  async pollMessages() {
160
182
  let consecutiveErrors = 0;
161
183
  while (this.running) {
162
184
  try {
163
- this.polling = true;
164
- /* c8 ignore next 1 */
165
- const visibilityTimeout = this.consumerOptions.visibilityTimeout ?? 30;
185
+ const visibilityTimeout = this.consumerOptions.visibilityTimeout;
166
186
  const messagesResult = await this.sqsClient.receiveMessage(this.queueARN, {
167
187
  WaitTimeSeconds: this.consumerOptions.waitTimeSeconds,
168
188
  MaxNumberOfMessages: this.consumerOptions.itemsPerRequest,
@@ -173,34 +193,28 @@ class SQSConsumer {
173
193
  const messages = await this.hooks.runHook("onPoll",
174
194
  /* c8 ignore next 1 */
175
195
  messagesResult.Messages ?? []);
176
- this.messagesOnFly += messages.length;
177
196
  if (messages.length) {
178
- const haTimeout = this.haExtendVisibilityTimeout(messages, visibilityTimeout);
197
+ const pendingReceipts = new Set(
198
+ // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
199
+ messages.map((message) => message.ReceiptHandle));
200
+ const haTimeout = this.haExtendVisibilityTimeout(pendingReceipts, visibilityTimeout);
179
201
  try {
180
- if (this.handlerOptions.parallelExecution !== false) {
181
- try {
182
- const results = await (0, p_map_1.default)(messages, async (message) => {
183
- return await this.runOnMessageHook(message);
184
- }, {
185
- concurrency: 10,
186
- stopOnError: false,
187
- });
188
- await this.deleteMessages(results);
189
- }
190
- finally {
191
- this.messagesOnFly -= messages.length;
192
- }
202
+ if (this.handlerOptions.parallelExecution) {
203
+ const results = await (0, p_map_1.default)(messages, async (message) => {
204
+ return await this.runOnMessageHook(message);
205
+ }, {
206
+ concurrency: 10,
207
+ stopOnError: false,
208
+ });
209
+ pendingReceipts.clear();
210
+ await this.deleteMessages(results);
193
211
  }
194
212
  else {
195
- const handlingMessages = [...messages];
196
- for (let i = 0; i < handlingMessages.length; i++) {
197
- try {
198
- const result = await this.runOnMessageHook(handlingMessages[i]);
199
- await this.deleteMessages([result]);
200
- }
201
- finally {
202
- this.messagesOnFly -= 1;
203
- }
213
+ for (const message of messages) {
214
+ const result = await this.runOnMessageHook(message);
215
+ // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
216
+ pendingReceipts.delete(message.ReceiptHandle);
217
+ await this.deleteMessages([result]);
204
218
  }
205
219
  }
206
220
  }
@@ -215,17 +229,20 @@ class SQSConsumer {
215
229
  await this.hooks.runHook("onSQSError", e);
216
230
  await (0, promises_1.setTimeout)(Math.min(1000 * 2 ** consecutiveErrors++, 30000));
217
231
  }
218
- this.polling = false;
219
232
  }
220
233
  }
221
234
  async start() {
222
235
  try {
223
236
  if (this.running)
224
237
  throw new Error("Consumer is already running");
238
+ if (this.pollLoop)
239
+ throw new Error("Consumer is stopping");
225
240
  if (this.destroyed)
226
241
  throw new Error("Consumer is destroyed");
227
242
  this.running = true;
228
- void this.pollMessages();
243
+ this.pollLoop = this.pollMessages().finally(() => {
244
+ this.pollLoop = null;
245
+ });
229
246
  }
230
247
  finally {
231
248
  await this.hooks.runHook("onStart", this);
@@ -236,9 +253,7 @@ class SQSConsumer {
236
253
  if (!this.running)
237
254
  throw new Error("Consumer is not running");
238
255
  this.running = false;
239
- while (this.messagesOnFly > 0 || this.polling) {
240
- await (0, promises_1.setTimeout)(500);
241
- }
256
+ await this.pollLoop;
242
257
  if (destroy) {
243
258
  await this.sqsClient.destroy(this.clientOptions.destroySigner);
244
259
  this.destroyed = true;
@@ -23,6 +23,8 @@ type ConsumerOptions = {
23
23
  itemsPerRequest?: number;
24
24
  messageAttributeNames?: string[];
25
25
  attributeNames?: string[];
26
+ errorDelaySeconds?: number;
27
+ errorDelayBackoff?: (attempt: number) => number;
26
28
  };
27
29
  type ConsumerHookName = Exclude<HookName, "onStart" | "onStop">;
28
30
  export type HooksOptions = {
@@ -46,9 +48,8 @@ export declare class SQSConsumer {
46
48
  private readonly sqsClient;
47
49
  private readonly hooks;
48
50
  private readonly messageHandler;
49
- private messagesOnFly;
50
51
  private running;
51
- private polling;
52
+ private pollLoop;
52
53
  private destroyed;
53
54
  private readonly logger;
54
55
  constructor(options: SQSConsumerOptions);
@@ -10,9 +10,8 @@ import { Hooks } from "./hooks.mjs";
10
10
  export { getAbortSignal } from "./context.mjs";
11
11
  export class SQSConsumer {
12
12
  constructor(options) {
13
- this.messagesOnFly = 0;
14
13
  this.running = false;
15
- this.polling = false;
14
+ this.pollLoop = null;
16
15
  this.destroyed = false;
17
16
  if (!options.queueARN)
18
17
  throw new Error("queueARN is required");
@@ -36,7 +35,16 @@ export class SQSConsumer {
36
35
  waitTimeSeconds: options.consumerOptions?.waitTimeSeconds ?? 20,
37
36
  itemsPerRequest: options.consumerOptions?.itemsPerRequest ?? 10,
38
37
  messageAttributeNames: options.consumerOptions?.messageAttributeNames ?? [],
39
- attributeNames: options.consumerOptions?.attributeNames ?? [],
38
+ attributeNames: [
39
+ ...new Set([
40
+ ...(options.consumerOptions?.attributeNames ?? []),
41
+ ...(options.consumerOptions?.errorDelayBackoff
42
+ ? ["ApproximateReceiveCount"]
43
+ : []),
44
+ ]),
45
+ ],
46
+ errorDelaySeconds: options.consumerOptions?.errorDelaySeconds ?? 0,
47
+ errorDelayBackoff: options.consumerOptions?.errorDelayBackoff,
40
48
  };
41
49
  this.sqsClient =
42
50
  options.clientOptions?.sqsClient ??
@@ -89,7 +97,7 @@ export class SQSConsumer {
89
97
  const err = new TimeoutError("Handler execution timed out");
90
98
  abortController.abort(err);
91
99
  reject(err);
92
- }, this.handlerOptions.executionTimeout);
100
+ }, this.handlerOptions.executionTimeout).unref();
93
101
  }),
94
102
  ]);
95
103
  }
@@ -113,19 +121,19 @@ export class SQSConsumer {
113
121
  clearTimeout(timeoutId);
114
122
  }
115
123
  }
116
- haExtendVisibilityTimeout(messages, visibilityTimeout) {
117
- if (this.handlerOptions.extendVisibilityTimeout !== false) {
118
- const processingMessages = messages.map(
119
- // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
120
- (message) => message.ReceiptHandle);
124
+ haExtendVisibilityTimeout(pendingReceipts, visibilityTimeout) {
125
+ if (this.handlerOptions.extendVisibilityTimeout && visibilityTimeout > 0) {
121
126
  return setInterval(async () => {
127
+ /* c8 ignore next 1 */
128
+ if (!pendingReceipts.size)
129
+ return;
122
130
  try {
123
- await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, processingMessages, visibilityTimeout);
131
+ await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, [...pendingReceipts], visibilityTimeout);
124
132
  } /* c8 ignore next 3 */
125
133
  catch (e) {
126
134
  await this.hooks.runHook("onSQSError", e);
127
135
  }
128
- }, visibilityTimeout * 1000 - 5);
136
+ }, visibilityTimeout * 500);
129
137
  }
130
138
  return null;
131
139
  }
@@ -134,10 +142,24 @@ export class SQSConsumer {
134
142
  const candidateToRelease = [];
135
143
  for (const result of messagesResults) {
136
144
  if (result.error) {
137
- // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
138
- candidateToRelease.push(result.message.ReceiptHandle);
145
+ let delaySeconds = this.consumerOptions.errorDelaySeconds;
146
+ if (this.consumerOptions.errorDelayBackoff) {
147
+ try {
148
+ delaySeconds = this.consumerOptions.errorDelayBackoff(
149
+ /* c8 ignore next 1 */
150
+ Number(result.message.Attributes?.ApproximateReceiveCount ?? 0));
151
+ }
152
+ catch (e) {
153
+ await this.hooks.runHook("onSQSError", e);
154
+ }
155
+ }
156
+ candidateToRelease.push({
157
+ // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
158
+ receiptHandle: result.message.ReceiptHandle,
159
+ visibilityTimeout: Math.min(43200, Math.max(0, Math.trunc(delaySeconds) || 0)),
160
+ });
139
161
  }
140
- else if (this.handlerOptions.deleteMessage !== false) {
162
+ else if (this.handlerOptions.deleteMessage) {
141
163
  // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
142
164
  candidatesToDelete.push(result.message.ReceiptHandle);
143
165
  }
@@ -146,16 +168,14 @@ export class SQSConsumer {
146
168
  await this.sqsClient.deleteMessageBatch(this.queueARN, candidatesToDelete);
147
169
  }
148
170
  if (candidateToRelease.length) {
149
- await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, candidateToRelease, 0);
171
+ await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, candidateToRelease);
150
172
  }
151
173
  }
152
174
  async pollMessages() {
153
175
  let consecutiveErrors = 0;
154
176
  while (this.running) {
155
177
  try {
156
- this.polling = true;
157
- /* c8 ignore next 1 */
158
- const visibilityTimeout = this.consumerOptions.visibilityTimeout ?? 30;
178
+ const visibilityTimeout = this.consumerOptions.visibilityTimeout;
159
179
  const messagesResult = await this.sqsClient.receiveMessage(this.queueARN, {
160
180
  WaitTimeSeconds: this.consumerOptions.waitTimeSeconds,
161
181
  MaxNumberOfMessages: this.consumerOptions.itemsPerRequest,
@@ -166,34 +186,28 @@ export class SQSConsumer {
166
186
  const messages = await this.hooks.runHook("onPoll",
167
187
  /* c8 ignore next 1 */
168
188
  messagesResult.Messages ?? []);
169
- this.messagesOnFly += messages.length;
170
189
  if (messages.length) {
171
- const haTimeout = this.haExtendVisibilityTimeout(messages, visibilityTimeout);
190
+ const pendingReceipts = new Set(
191
+ // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
192
+ messages.map((message) => message.ReceiptHandle));
193
+ const haTimeout = this.haExtendVisibilityTimeout(pendingReceipts, visibilityTimeout);
172
194
  try {
173
- if (this.handlerOptions.parallelExecution !== false) {
174
- try {
175
- const results = await pMap(messages, async (message) => {
176
- return await this.runOnMessageHook(message);
177
- }, {
178
- concurrency: 10,
179
- stopOnError: false,
180
- });
181
- await this.deleteMessages(results);
182
- }
183
- finally {
184
- this.messagesOnFly -= messages.length;
185
- }
195
+ if (this.handlerOptions.parallelExecution) {
196
+ const results = await pMap(messages, async (message) => {
197
+ return await this.runOnMessageHook(message);
198
+ }, {
199
+ concurrency: 10,
200
+ stopOnError: false,
201
+ });
202
+ pendingReceipts.clear();
203
+ await this.deleteMessages(results);
186
204
  }
187
205
  else {
188
- const handlingMessages = [...messages];
189
- for (let i = 0; i < handlingMessages.length; i++) {
190
- try {
191
- const result = await this.runOnMessageHook(handlingMessages[i]);
192
- await this.deleteMessages([result]);
193
- }
194
- finally {
195
- this.messagesOnFly -= 1;
196
- }
206
+ for (const message of messages) {
207
+ const result = await this.runOnMessageHook(message);
208
+ // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
209
+ pendingReceipts.delete(message.ReceiptHandle);
210
+ await this.deleteMessages([result]);
197
211
  }
198
212
  }
199
213
  }
@@ -208,17 +222,20 @@ export class SQSConsumer {
208
222
  await this.hooks.runHook("onSQSError", e);
209
223
  await setTimeoutAsync(Math.min(1000 * 2 ** consecutiveErrors++, 30000));
210
224
  }
211
- this.polling = false;
212
225
  }
213
226
  }
214
227
  async start() {
215
228
  try {
216
229
  if (this.running)
217
230
  throw new Error("Consumer is already running");
231
+ if (this.pollLoop)
232
+ throw new Error("Consumer is stopping");
218
233
  if (this.destroyed)
219
234
  throw new Error("Consumer is destroyed");
220
235
  this.running = true;
221
- void this.pollMessages();
236
+ this.pollLoop = this.pollMessages().finally(() => {
237
+ this.pollLoop = null;
238
+ });
222
239
  }
223
240
  finally {
224
241
  await this.hooks.runHook("onStart", this);
@@ -229,9 +246,7 @@ export class SQSConsumer {
229
246
  if (!this.running)
230
247
  throw new Error("Consumer is not running");
231
248
  this.running = false;
232
- while (this.messagesOnFly > 0 || this.polling) {
233
- await setTimeoutAsync(500);
234
- }
249
+ await this.pollLoop;
235
250
  if (destroy) {
236
251
  await this.sqsClient.destroy(this.clientOptions.destroySigner);
237
252
  this.destroyed = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fgiova/sqs-consumer",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "main": "./dist/cjs/index.js",
5
5
  "types": "./dist/cjs/index.d.ts",
6
6
  "exports": {
@@ -56,7 +56,7 @@
56
56
  ]
57
57
  },
58
58
  "dependencies": {
59
- "@fgiova/mini-sqs-client": "^5.1.0",
59
+ "@fgiova/mini-sqs-client": "^5.2.0",
60
60
  "@watchable/unpromise": "^1.0.2",
61
61
  "p-map": "^7.0.3"
62
62
  },