@fgiova/sqs-consumer 3.3.0 → 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
@@ -87,6 +87,7 @@ const consumer = new SQSConsumer({
87
87
  | attributeNames | string[] | [] | The attribute names to be included in the response |
88
88
  | errorDelaySeconds | number | 0 | Seconds a failed message stays invisible before it can be received again |
89
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 |
90
91
 
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.
92
93
 
@@ -105,6 +106,23 @@ const consumer = new SQSConsumer({
105
106
  });
106
107
  ```
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
+ ```
125
+
108
126
  ### Hooks
109
127
  | Option | Type | Description |
110
128
  |------------------|-----------------------------------------------------------------------|---------------------------------------------------------------------|
@@ -113,6 +131,7 @@ const consumer = new SQSConsumer({
113
131
  | onHandlerSuccess | (message: Message) => Promise<Message> | Called when the consumer handles a message successfully |
114
132
  | onHandlerTimeout | (message: Message) => Promise<boolean> | Called when the consumer handler execution exceed executionTimeout |
115
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 |
116
135
  | onSuccess | (message: Message) => Promise<boolean> | Called when the consumer handler execution finishes successfully |
117
136
  | onError | (hook: HookName, message: Message, error: Error) => Promise<boolean> | Called when the consumer handler execution throws an uncaught error |
118
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]: [],
@@ -25,6 +25,7 @@ type ConsumerOptions = {
25
25
  attributeNames?: string[];
26
26
  errorDelaySeconds?: number;
27
27
  errorDelayBackoff?: (attempt: number) => number;
28
+ maxReceiveCount?: number | ((attempt: number) => number);
28
29
  };
29
30
  type ConsumerHookName = Exclude<HookName, "onStart" | "onStop">;
30
31
  export type HooksOptions = {
package/dist/cjs/index.js CHANGED
@@ -45,13 +45,15 @@ class SQSConsumer {
45
45
  attributeNames: [
46
46
  ...new Set([
47
47
  ...(options.consumerOptions?.attributeNames ?? []),
48
- ...(options.consumerOptions?.errorDelayBackoff
48
+ ...(options.consumerOptions?.errorDelayBackoff ||
49
+ options.consumerOptions?.maxReceiveCount !== undefined
49
50
  ? ["ApproximateReceiveCount"]
50
51
  : []),
51
52
  ]),
52
53
  ],
53
54
  errorDelaySeconds: options.consumerOptions?.errorDelaySeconds ?? 0,
54
55
  errorDelayBackoff: options.consumerOptions?.errorDelayBackoff,
56
+ maxReceiveCount: options.consumerOptions?.maxReceiveCount,
55
57
  };
56
58
  this.sqsClient =
57
59
  options.clientOptions?.sqsClient ??
@@ -150,6 +152,28 @@ class SQSConsumer {
150
152
  for (const result of messagesResults) {
151
153
  if (result.error) {
152
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
+ }
153
177
  if (this.consumerOptions.errorDelayBackoff) {
154
178
  try {
155
179
  delaySeconds = this.consumerOptions.errorDelayBackoff(
@@ -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]: [],
@@ -25,6 +25,7 @@ type ConsumerOptions = {
25
25
  attributeNames?: string[];
26
26
  errorDelaySeconds?: number;
27
27
  errorDelayBackoff?: (attempt: number) => number;
28
+ maxReceiveCount?: number | ((attempt: number) => number);
28
29
  };
29
30
  type ConsumerHookName = Exclude<HookName, "onStart" | "onStop">;
30
31
  export type HooksOptions = {
@@ -38,13 +38,15 @@ export class SQSConsumer {
38
38
  attributeNames: [
39
39
  ...new Set([
40
40
  ...(options.consumerOptions?.attributeNames ?? []),
41
- ...(options.consumerOptions?.errorDelayBackoff
41
+ ...(options.consumerOptions?.errorDelayBackoff ||
42
+ options.consumerOptions?.maxReceiveCount !== undefined
42
43
  ? ["ApproximateReceiveCount"]
43
44
  : []),
44
45
  ]),
45
46
  ],
46
47
  errorDelaySeconds: options.consumerOptions?.errorDelaySeconds ?? 0,
47
48
  errorDelayBackoff: options.consumerOptions?.errorDelayBackoff,
49
+ maxReceiveCount: options.consumerOptions?.maxReceiveCount,
48
50
  };
49
51
  this.sqsClient =
50
52
  options.clientOptions?.sqsClient ??
@@ -143,6 +145,28 @@ export class SQSConsumer {
143
145
  for (const result of messagesResults) {
144
146
  if (result.error) {
145
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
+ }
146
170
  if (this.consumerOptions.errorDelayBackoff) {
147
171
  try {
148
172
  delaySeconds = this.consumerOptions.errorDelayBackoff(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fgiova/sqs-consumer",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "main": "./dist/cjs/index.js",
5
5
  "types": "./dist/cjs/index.d.ts",
6
6
  "exports": {