@fgiova/sqs-consumer 3.2.1 → 3.4.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,50 @@ 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
+ | maxReceiveCount | number / (attempt: number) => number | | Receive count after which a failed message triggers `onMaxReceiveCountExceeded`. A function receives the `ApproximateReceiveCount` and returns the threshold |
91
+
92
+ 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.
93
+
94
+ > **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.
95
+ >
96
+ > 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.
97
+
98
+ ```typescript
99
+ const consumer = new SQSConsumer({
100
+ queueARN: "arn:aws:sqs:eu-central-1:000000000000:test",
101
+ handler: async (message) => { /* ... */ },
102
+ consumerOptions: {
103
+ // 1st failure: 2s, 2nd: 4s, 3rd: 8s ... capped at 15 minutes
104
+ errorDelayBackoff: (attempt) => Math.min(2 ** attempt, 900),
105
+ },
106
+ });
107
+ ```
108
+
109
+ When a message fails and its `ApproximateReceiveCount` reaches `maxReceiveCount`, the `onMaxReceiveCountExceeded` hook is called with the message and its receive count. **The message is deleted unless a hook returns `false`**, including when no hook is registered: return `false` to release it with the usual error delay instead. When `maxReceiveCount` is set, `ApproximateReceiveCount` is added to `attributeNames` automatically. If the `maxReceiveCount` function throws, the error is reported to `onSQSError` and the message is released.
110
+
111
+ > **Note:** if the queue has a redrive policy, set `maxReceiveCount` below the queue's own `maxReceiveCount`, or SQS moves the message to the DLQ before the hook is called.
112
+
113
+ ```typescript
114
+ const consumer = new SQSConsumer({
115
+ queueARN: "arn:aws:sqs:eu-central-1:000000000000:test",
116
+ handler: async (message) => { /* ... */ },
117
+ consumerOptions: {
118
+ maxReceiveCount: 5,
119
+ },
120
+ });
121
+ consumer.addHook("onMaxReceiveCountExceeded", async (message, count) => {
122
+ await archive(message); // keep a copy, then let the consumer delete it
123
+ });
124
+ ```
88
125
 
89
126
  ### Hooks
90
127
  | Option | Type | Description |
@@ -94,6 +131,7 @@ const consumer = new SQSConsumer({
94
131
  | onHandlerSuccess | (message: Message) => Promise<Message> | Called when the consumer handles a message successfully |
95
132
  | onHandlerTimeout | (message: Message) => Promise<boolean> | Called when the consumer handler execution exceed executionTimeout |
96
133
  | onHandlerError | (message: Message, error: Error) => Promise<boolean> | Called when the consumer handler execution throws an error |
134
+ | onMaxReceiveCountExceeded | (message: Message, count: number) => Promise<boolean> | Called when a failed message reaches `maxReceiveCount`. Return `false` to keep the message, otherwise it is deleted |
97
135
  | onSuccess | (message: Message) => Promise<boolean> | Called when the consumer handler execution finishes successfully |
98
136
  | onError | (hook: HookName, message: Message, error: Error) => Promise<boolean> | Called when the consumer handler execution throws an uncaught error |
99
137
  | onSQSError | (error: Error, message?: Message) => Promise<void> | Called when the consumer receives an error from the SQS service |
@@ -1,7 +1,7 @@
1
1
  /** biome-ignore-all lint/complexity/noBannedTypes: Function required as generic */
2
2
  import type { Message } from "@fgiova/mini-sqs-client";
3
3
  import type { Logger } from "./logger";
4
- export type HookName = "onStart" | "onStop" | "onPoll" | "onMessage" | "onHandlerSuccess" | "onHandlerTimeout" | "onHandlerError" | "onSuccess" | "onError" | "onSQSError";
4
+ export type HookName = "onStart" | "onStop" | "onPoll" | "onMessage" | "onHandlerSuccess" | "onHandlerTimeout" | "onHandlerError" | "onMaxReceiveCountExceeded" | "onSuccess" | "onError" | "onSQSError";
5
5
  export type HookSignatureMap = {
6
6
  onStart: {
7
7
  args: [sqsConsumer: unknown];
@@ -31,6 +31,10 @@ export type HookSignatureMap = {
31
31
  args: [message: Message, error: Error];
32
32
  return: boolean;
33
33
  };
34
+ onMaxReceiveCountExceeded: {
35
+ args: [message: Message, count: number];
36
+ return: boolean;
37
+ };
34
38
  onSuccess: {
35
39
  args: [message: Message];
36
40
  return: boolean;
package/dist/cjs/hooks.js CHANGED
@@ -39,6 +39,11 @@ class Hooks {
39
39
  throwable: false,
40
40
  type: "boolean-return",
41
41
  },
42
+ onMaxReceiveCountExceeded: {
43
+ S: Symbol("maxReceiveCountExceeded"),
44
+ throwable: false,
45
+ type: "boolean-return",
46
+ },
42
47
  onSuccess: {
43
48
  S: Symbol("success"),
44
49
  throwable: false,
@@ -64,6 +69,7 @@ class Hooks {
64
69
  [this.hookSymbols.onHandlerSuccess.S]: [],
65
70
  [this.hookSymbols.onHandlerTimeout.S]: [],
66
71
  [this.hookSymbols.onHandlerError.S]: [],
72
+ [this.hookSymbols.onMaxReceiveCountExceeded.S]: [],
67
73
  [this.hookSymbols.onSuccess.S]: [],
68
74
  [this.hookSymbols.onError.S]: [],
69
75
  [this.hookSymbols.onSQSError.S]: [],
@@ -23,6 +23,9 @@ type ConsumerOptions = {
23
23
  itemsPerRequest?: number;
24
24
  messageAttributeNames?: string[];
25
25
  attributeNames?: string[];
26
+ errorDelaySeconds?: number;
27
+ errorDelayBackoff?: (attempt: number) => number;
28
+ maxReceiveCount?: number | ((attempt: number) => number);
26
29
  };
27
30
  type ConsumerHookName = Exclude<HookName, "onStart" | "onStop">;
28
31
  export type HooksOptions = {
package/dist/cjs/index.js CHANGED
@@ -42,7 +42,18 @@ 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
+ options.consumerOptions?.maxReceiveCount !== undefined
50
+ ? ["ApproximateReceiveCount"]
51
+ : []),
52
+ ]),
53
+ ],
54
+ errorDelaySeconds: options.consumerOptions?.errorDelaySeconds ?? 0,
55
+ errorDelayBackoff: options.consumerOptions?.errorDelayBackoff,
56
+ maxReceiveCount: options.consumerOptions?.maxReceiveCount,
46
57
  };
47
58
  this.sqsClient =
48
59
  options.clientOptions?.sqsClient ??
@@ -120,9 +131,11 @@ class SQSConsumer {
120
131
  }
121
132
  }
122
133
  haExtendVisibilityTimeout(pendingReceipts, visibilityTimeout) {
123
- if (this.handlerOptions.extendVisibilityTimeout !== false &&
124
- visibilityTimeout > 0) {
134
+ if (this.handlerOptions.extendVisibilityTimeout && visibilityTimeout > 0) {
125
135
  return setInterval(async () => {
136
+ /* c8 ignore next 1 */
137
+ if (!pendingReceipts.size)
138
+ return;
126
139
  try {
127
140
  await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, [...pendingReceipts], visibilityTimeout);
128
141
  } /* c8 ignore next 3 */
@@ -138,10 +151,46 @@ class SQSConsumer {
138
151
  const candidateToRelease = [];
139
152
  for (const result of messagesResults) {
140
153
  if (result.error) {
141
- // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
142
- candidateToRelease.push(result.message.ReceiptHandle);
154
+ let delaySeconds = this.consumerOptions.errorDelaySeconds;
155
+ const maxReceiveCount = this.consumerOptions.maxReceiveCount;
156
+ if (maxReceiveCount !== undefined) {
157
+ try {
158
+ const receiveCount = Number(
159
+ /* c8 ignore next 1 */
160
+ result.message.Attributes?.ApproximateReceiveCount ?? 0);
161
+ if (receiveCount >=
162
+ (typeof maxReceiveCount === "function"
163
+ ? maxReceiveCount(receiveCount)
164
+ : maxReceiveCount)) {
165
+ const toDelete = await this.hooks.runHook("onMaxReceiveCountExceeded", result.message, receiveCount);
166
+ if (toDelete) {
167
+ // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
168
+ candidatesToDelete.push(result.message.ReceiptHandle);
169
+ continue;
170
+ }
171
+ }
172
+ }
173
+ catch (e) {
174
+ await this.hooks.runHook("onSQSError", e);
175
+ }
176
+ }
177
+ if (this.consumerOptions.errorDelayBackoff) {
178
+ try {
179
+ delaySeconds = this.consumerOptions.errorDelayBackoff(
180
+ /* c8 ignore next 1 */
181
+ Number(result.message.Attributes?.ApproximateReceiveCount ?? 0));
182
+ }
183
+ catch (e) {
184
+ await this.hooks.runHook("onSQSError", e);
185
+ }
186
+ }
187
+ candidateToRelease.push({
188
+ // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
189
+ receiptHandle: result.message.ReceiptHandle,
190
+ visibilityTimeout: Math.min(43200, Math.max(0, Math.trunc(delaySeconds) || 0)),
191
+ });
143
192
  }
144
- else if (this.handlerOptions.deleteMessage !== false) {
193
+ else if (this.handlerOptions.deleteMessage) {
145
194
  // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
146
195
  candidatesToDelete.push(result.message.ReceiptHandle);
147
196
  }
@@ -150,15 +199,14 @@ class SQSConsumer {
150
199
  await this.sqsClient.deleteMessageBatch(this.queueARN, candidatesToDelete);
151
200
  }
152
201
  if (candidateToRelease.length) {
153
- await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, candidateToRelease, 0);
202
+ await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, candidateToRelease);
154
203
  }
155
204
  }
156
205
  async pollMessages() {
157
206
  let consecutiveErrors = 0;
158
207
  while (this.running) {
159
208
  try {
160
- /* c8 ignore next 1 */
161
- const visibilityTimeout = this.consumerOptions.visibilityTimeout ?? 30;
209
+ const visibilityTimeout = this.consumerOptions.visibilityTimeout;
162
210
  const messagesResult = await this.sqsClient.receiveMessage(this.queueARN, {
163
211
  WaitTimeSeconds: this.consumerOptions.waitTimeSeconds,
164
212
  MaxNumberOfMessages: this.consumerOptions.itemsPerRequest,
@@ -175,13 +223,14 @@ class SQSConsumer {
175
223
  messages.map((message) => message.ReceiptHandle));
176
224
  const haTimeout = this.haExtendVisibilityTimeout(pendingReceipts, visibilityTimeout);
177
225
  try {
178
- if (this.handlerOptions.parallelExecution !== false) {
226
+ if (this.handlerOptions.parallelExecution) {
179
227
  const results = await (0, p_map_1.default)(messages, async (message) => {
180
228
  return await this.runOnMessageHook(message);
181
229
  }, {
182
230
  concurrency: 10,
183
231
  stopOnError: false,
184
232
  });
233
+ pendingReceipts.clear();
185
234
  await this.deleteMessages(results);
186
235
  }
187
236
  else {
@@ -1,7 +1,7 @@
1
1
  /** biome-ignore-all lint/complexity/noBannedTypes: Function required as generic */
2
2
  import type { Message } from "@fgiova/mini-sqs-client";
3
3
  import type { Logger } from "./logger.mjs";
4
- export type HookName = "onStart" | "onStop" | "onPoll" | "onMessage" | "onHandlerSuccess" | "onHandlerTimeout" | "onHandlerError" | "onSuccess" | "onError" | "onSQSError";
4
+ export type HookName = "onStart" | "onStop" | "onPoll" | "onMessage" | "onHandlerSuccess" | "onHandlerTimeout" | "onHandlerError" | "onMaxReceiveCountExceeded" | "onSuccess" | "onError" | "onSQSError";
5
5
  export type HookSignatureMap = {
6
6
  onStart: {
7
7
  args: [sqsConsumer: unknown];
@@ -31,6 +31,10 @@ export type HookSignatureMap = {
31
31
  args: [message: Message, error: Error];
32
32
  return: boolean;
33
33
  };
34
+ onMaxReceiveCountExceeded: {
35
+ args: [message: Message, count: number];
36
+ return: boolean;
37
+ };
34
38
  onSuccess: {
35
39
  args: [message: Message];
36
40
  return: boolean;
@@ -36,6 +36,11 @@ export class Hooks {
36
36
  throwable: false,
37
37
  type: "boolean-return",
38
38
  },
39
+ onMaxReceiveCountExceeded: {
40
+ S: Symbol("maxReceiveCountExceeded"),
41
+ throwable: false,
42
+ type: "boolean-return",
43
+ },
39
44
  onSuccess: {
40
45
  S: Symbol("success"),
41
46
  throwable: false,
@@ -61,6 +66,7 @@ export class Hooks {
61
66
  [this.hookSymbols.onHandlerSuccess.S]: [],
62
67
  [this.hookSymbols.onHandlerTimeout.S]: [],
63
68
  [this.hookSymbols.onHandlerError.S]: [],
69
+ [this.hookSymbols.onMaxReceiveCountExceeded.S]: [],
64
70
  [this.hookSymbols.onSuccess.S]: [],
65
71
  [this.hookSymbols.onError.S]: [],
66
72
  [this.hookSymbols.onSQSError.S]: [],
@@ -23,6 +23,9 @@ type ConsumerOptions = {
23
23
  itemsPerRequest?: number;
24
24
  messageAttributeNames?: string[];
25
25
  attributeNames?: string[];
26
+ errorDelaySeconds?: number;
27
+ errorDelayBackoff?: (attempt: number) => number;
28
+ maxReceiveCount?: number | ((attempt: number) => number);
26
29
  };
27
30
  type ConsumerHookName = Exclude<HookName, "onStart" | "onStop">;
28
31
  export type HooksOptions = {
@@ -35,7 +35,18 @@ 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
+ options.consumerOptions?.maxReceiveCount !== undefined
43
+ ? ["ApproximateReceiveCount"]
44
+ : []),
45
+ ]),
46
+ ],
47
+ errorDelaySeconds: options.consumerOptions?.errorDelaySeconds ?? 0,
48
+ errorDelayBackoff: options.consumerOptions?.errorDelayBackoff,
49
+ maxReceiveCount: options.consumerOptions?.maxReceiveCount,
39
50
  };
40
51
  this.sqsClient =
41
52
  options.clientOptions?.sqsClient ??
@@ -113,9 +124,11 @@ export class SQSConsumer {
113
124
  }
114
125
  }
115
126
  haExtendVisibilityTimeout(pendingReceipts, visibilityTimeout) {
116
- if (this.handlerOptions.extendVisibilityTimeout !== false &&
117
- visibilityTimeout > 0) {
127
+ if (this.handlerOptions.extendVisibilityTimeout && visibilityTimeout > 0) {
118
128
  return setInterval(async () => {
129
+ /* c8 ignore next 1 */
130
+ if (!pendingReceipts.size)
131
+ return;
119
132
  try {
120
133
  await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, [...pendingReceipts], visibilityTimeout);
121
134
  } /* c8 ignore next 3 */
@@ -131,10 +144,46 @@ export class SQSConsumer {
131
144
  const candidateToRelease = [];
132
145
  for (const result of messagesResults) {
133
146
  if (result.error) {
134
- // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
135
- candidateToRelease.push(result.message.ReceiptHandle);
147
+ let delaySeconds = this.consumerOptions.errorDelaySeconds;
148
+ const maxReceiveCount = this.consumerOptions.maxReceiveCount;
149
+ if (maxReceiveCount !== undefined) {
150
+ try {
151
+ const receiveCount = Number(
152
+ /* c8 ignore next 1 */
153
+ result.message.Attributes?.ApproximateReceiveCount ?? 0);
154
+ if (receiveCount >=
155
+ (typeof maxReceiveCount === "function"
156
+ ? maxReceiveCount(receiveCount)
157
+ : maxReceiveCount)) {
158
+ const toDelete = await this.hooks.runHook("onMaxReceiveCountExceeded", result.message, receiveCount);
159
+ if (toDelete) {
160
+ // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
161
+ candidatesToDelete.push(result.message.ReceiptHandle);
162
+ continue;
163
+ }
164
+ }
165
+ }
166
+ catch (e) {
167
+ await this.hooks.runHook("onSQSError", e);
168
+ }
169
+ }
170
+ if (this.consumerOptions.errorDelayBackoff) {
171
+ try {
172
+ delaySeconds = this.consumerOptions.errorDelayBackoff(
173
+ /* c8 ignore next 1 */
174
+ Number(result.message.Attributes?.ApproximateReceiveCount ?? 0));
175
+ }
176
+ catch (e) {
177
+ await this.hooks.runHook("onSQSError", e);
178
+ }
179
+ }
180
+ candidateToRelease.push({
181
+ // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
182
+ receiptHandle: result.message.ReceiptHandle,
183
+ visibilityTimeout: Math.min(43200, Math.max(0, Math.trunc(delaySeconds) || 0)),
184
+ });
136
185
  }
137
- else if (this.handlerOptions.deleteMessage !== false) {
186
+ else if (this.handlerOptions.deleteMessage) {
138
187
  // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
139
188
  candidatesToDelete.push(result.message.ReceiptHandle);
140
189
  }
@@ -143,15 +192,14 @@ export class SQSConsumer {
143
192
  await this.sqsClient.deleteMessageBatch(this.queueARN, candidatesToDelete);
144
193
  }
145
194
  if (candidateToRelease.length) {
146
- await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, candidateToRelease, 0);
195
+ await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, candidateToRelease);
147
196
  }
148
197
  }
149
198
  async pollMessages() {
150
199
  let consecutiveErrors = 0;
151
200
  while (this.running) {
152
201
  try {
153
- /* c8 ignore next 1 */
154
- const visibilityTimeout = this.consumerOptions.visibilityTimeout ?? 30;
202
+ const visibilityTimeout = this.consumerOptions.visibilityTimeout;
155
203
  const messagesResult = await this.sqsClient.receiveMessage(this.queueARN, {
156
204
  WaitTimeSeconds: this.consumerOptions.waitTimeSeconds,
157
205
  MaxNumberOfMessages: this.consumerOptions.itemsPerRequest,
@@ -168,13 +216,14 @@ export class SQSConsumer {
168
216
  messages.map((message) => message.ReceiptHandle));
169
217
  const haTimeout = this.haExtendVisibilityTimeout(pendingReceipts, visibilityTimeout);
170
218
  try {
171
- if (this.handlerOptions.parallelExecution !== false) {
219
+ if (this.handlerOptions.parallelExecution) {
172
220
  const results = await pMap(messages, async (message) => {
173
221
  return await this.runOnMessageHook(message);
174
222
  }, {
175
223
  concurrency: 10,
176
224
  stopOnError: false,
177
225
  });
226
+ pendingReceipts.clear();
178
227
  await this.deleteMessages(results);
179
228
  }
180
229
  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.4.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
  },