@fgiova/sqs-consumer 3.2.1 → 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 = {
package/dist/cjs/index.js CHANGED
@@ -42,7 +42,16 @@ class SQSConsumer {
42
42
  waitTimeSeconds: options.consumerOptions?.waitTimeSeconds ?? 20,
43
43
  itemsPerRequest: options.consumerOptions?.itemsPerRequest ?? 10,
44
44
  messageAttributeNames: options.consumerOptions?.messageAttributeNames ?? [],
45
- 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,
46
55
  };
47
56
  this.sqsClient =
48
57
  options.clientOptions?.sqsClient ??
@@ -120,9 +129,11 @@ class SQSConsumer {
120
129
  }
121
130
  }
122
131
  haExtendVisibilityTimeout(pendingReceipts, visibilityTimeout) {
123
- if (this.handlerOptions.extendVisibilityTimeout !== false &&
124
- visibilityTimeout > 0) {
132
+ if (this.handlerOptions.extendVisibilityTimeout && visibilityTimeout > 0) {
125
133
  return setInterval(async () => {
134
+ /* c8 ignore next 1 */
135
+ if (!pendingReceipts.size)
136
+ return;
126
137
  try {
127
138
  await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, [...pendingReceipts], visibilityTimeout);
128
139
  } /* c8 ignore next 3 */
@@ -138,10 +149,24 @@ class SQSConsumer {
138
149
  const candidateToRelease = [];
139
150
  for (const result of messagesResults) {
140
151
  if (result.error) {
141
- // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
142
- 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
+ });
143
168
  }
144
- else if (this.handlerOptions.deleteMessage !== false) {
169
+ else if (this.handlerOptions.deleteMessage) {
145
170
  // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
146
171
  candidatesToDelete.push(result.message.ReceiptHandle);
147
172
  }
@@ -150,15 +175,14 @@ class SQSConsumer {
150
175
  await this.sqsClient.deleteMessageBatch(this.queueARN, candidatesToDelete);
151
176
  }
152
177
  if (candidateToRelease.length) {
153
- await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, candidateToRelease, 0);
178
+ await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, candidateToRelease);
154
179
  }
155
180
  }
156
181
  async pollMessages() {
157
182
  let consecutiveErrors = 0;
158
183
  while (this.running) {
159
184
  try {
160
- /* c8 ignore next 1 */
161
- const visibilityTimeout = this.consumerOptions.visibilityTimeout ?? 30;
185
+ const visibilityTimeout = this.consumerOptions.visibilityTimeout;
162
186
  const messagesResult = await this.sqsClient.receiveMessage(this.queueARN, {
163
187
  WaitTimeSeconds: this.consumerOptions.waitTimeSeconds,
164
188
  MaxNumberOfMessages: this.consumerOptions.itemsPerRequest,
@@ -175,13 +199,14 @@ class SQSConsumer {
175
199
  messages.map((message) => message.ReceiptHandle));
176
200
  const haTimeout = this.haExtendVisibilityTimeout(pendingReceipts, visibilityTimeout);
177
201
  try {
178
- if (this.handlerOptions.parallelExecution !== false) {
202
+ if (this.handlerOptions.parallelExecution) {
179
203
  const results = await (0, p_map_1.default)(messages, async (message) => {
180
204
  return await this.runOnMessageHook(message);
181
205
  }, {
182
206
  concurrency: 10,
183
207
  stopOnError: false,
184
208
  });
209
+ pendingReceipts.clear();
185
210
  await this.deleteMessages(results);
186
211
  }
187
212
  else {
@@ -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 = {
@@ -35,7 +35,16 @@ export class SQSConsumer {
35
35
  waitTimeSeconds: options.consumerOptions?.waitTimeSeconds ?? 20,
36
36
  itemsPerRequest: options.consumerOptions?.itemsPerRequest ?? 10,
37
37
  messageAttributeNames: options.consumerOptions?.messageAttributeNames ?? [],
38
- 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,
39
48
  };
40
49
  this.sqsClient =
41
50
  options.clientOptions?.sqsClient ??
@@ -113,9 +122,11 @@ export class SQSConsumer {
113
122
  }
114
123
  }
115
124
  haExtendVisibilityTimeout(pendingReceipts, visibilityTimeout) {
116
- if (this.handlerOptions.extendVisibilityTimeout !== false &&
117
- visibilityTimeout > 0) {
125
+ if (this.handlerOptions.extendVisibilityTimeout && visibilityTimeout > 0) {
118
126
  return setInterval(async () => {
127
+ /* c8 ignore next 1 */
128
+ if (!pendingReceipts.size)
129
+ return;
119
130
  try {
120
131
  await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, [...pendingReceipts], visibilityTimeout);
121
132
  } /* c8 ignore next 3 */
@@ -131,10 +142,24 @@ export class SQSConsumer {
131
142
  const candidateToRelease = [];
132
143
  for (const result of messagesResults) {
133
144
  if (result.error) {
134
- // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
135
- 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
+ });
136
161
  }
137
- else if (this.handlerOptions.deleteMessage !== false) {
162
+ else if (this.handlerOptions.deleteMessage) {
138
163
  // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
139
164
  candidatesToDelete.push(result.message.ReceiptHandle);
140
165
  }
@@ -143,15 +168,14 @@ export class SQSConsumer {
143
168
  await this.sqsClient.deleteMessageBatch(this.queueARN, candidatesToDelete);
144
169
  }
145
170
  if (candidateToRelease.length) {
146
- await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, candidateToRelease, 0);
171
+ await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, candidateToRelease);
147
172
  }
148
173
  }
149
174
  async pollMessages() {
150
175
  let consecutiveErrors = 0;
151
176
  while (this.running) {
152
177
  try {
153
- /* c8 ignore next 1 */
154
- const visibilityTimeout = this.consumerOptions.visibilityTimeout ?? 30;
178
+ const visibilityTimeout = this.consumerOptions.visibilityTimeout;
155
179
  const messagesResult = await this.sqsClient.receiveMessage(this.queueARN, {
156
180
  WaitTimeSeconds: this.consumerOptions.waitTimeSeconds,
157
181
  MaxNumberOfMessages: this.consumerOptions.itemsPerRequest,
@@ -168,13 +192,14 @@ export class SQSConsumer {
168
192
  messages.map((message) => message.ReceiptHandle));
169
193
  const haTimeout = this.haExtendVisibilityTimeout(pendingReceipts, visibilityTimeout);
170
194
  try {
171
- if (this.handlerOptions.parallelExecution !== false) {
195
+ if (this.handlerOptions.parallelExecution) {
172
196
  const results = await pMap(messages, async (message) => {
173
197
  return await this.runOnMessageHook(message);
174
198
  }, {
175
199
  concurrency: 10,
176
200
  stopOnError: false,
177
201
  });
202
+ pendingReceipts.clear();
178
203
  await this.deleteMessages(results);
179
204
  }
180
205
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fgiova/sqs-consumer",
3
- "version": "3.2.1",
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
  },