@remit/sqs-client 0.0.14 → 0.0.16
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/package.json +5 -1
- package/src/attempt-budget.test.ts +58 -0
- package/src/attempt-budget.ts +24 -0
- package/src/poller.ts +2 -1
- package/src/producer.test.ts +30 -1
- package/src/producer.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/sqs-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.16",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
"types": "./src/index.ts",
|
|
10
10
|
"default": "./src/index.ts"
|
|
11
11
|
},
|
|
12
|
+
"./attempt-budget": {
|
|
13
|
+
"types": "./src/attempt-budget.ts",
|
|
14
|
+
"default": "./src/attempt-budget.ts"
|
|
15
|
+
},
|
|
12
16
|
"./heartbeat": {
|
|
13
17
|
"types": "./src/heartbeat.ts",
|
|
14
18
|
"default": "./src/heartbeat.ts"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, describe, it } from "node:test";
|
|
3
|
+
import { attemptBudget } from "./attempt-budget.js";
|
|
4
|
+
|
|
5
|
+
describe("attemptBudget", () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
delete process.env.EXAMPLE_MAX_ATTEMPTS;
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("parses the named env var as a base-10 integer", () => {
|
|
11
|
+
assert.equal(
|
|
12
|
+
attemptBudget("EXAMPLE_MAX_ATTEMPTS", 3, {
|
|
13
|
+
EXAMPLE_MAX_ATTEMPTS: "5",
|
|
14
|
+
}),
|
|
15
|
+
5,
|
|
16
|
+
);
|
|
17
|
+
assert.equal(
|
|
18
|
+
attemptBudget("EXAMPLE_MAX_ATTEMPTS", 3, {
|
|
19
|
+
EXAMPLE_MAX_ATTEMPTS: "10",
|
|
20
|
+
}),
|
|
21
|
+
10,
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("reads only the named var, so budgets stay independent", () => {
|
|
26
|
+
assert.equal(
|
|
27
|
+
attemptBudget("EXAMPLE_MAX_ATTEMPTS", 3, {
|
|
28
|
+
OTHER_MAX_ATTEMPTS: "9",
|
|
29
|
+
}),
|
|
30
|
+
3,
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("falls back when the var is absent or empty", () => {
|
|
35
|
+
assert.equal(attemptBudget("EXAMPLE_MAX_ATTEMPTS", 3, {}), 3);
|
|
36
|
+
assert.equal(
|
|
37
|
+
attemptBudget("EXAMPLE_MAX_ATTEMPTS", 7, { EXAMPLE_MAX_ATTEMPTS: "" }),
|
|
38
|
+
7,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("falls back on a value that is not a positive integer", () => {
|
|
43
|
+
for (const raw of ["nope", "0", "-1", "NaN", " "]) {
|
|
44
|
+
assert.equal(
|
|
45
|
+
attemptBudget("EXAMPLE_MAX_ATTEMPTS", 3, {
|
|
46
|
+
EXAMPLE_MAX_ATTEMPTS: raw,
|
|
47
|
+
}),
|
|
48
|
+
3,
|
|
49
|
+
`expected ${JSON.stringify(raw)} to fall back`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("reads process.env when no environment is supplied", () => {
|
|
55
|
+
process.env.EXAMPLE_MAX_ATTEMPTS = "4";
|
|
56
|
+
assert.equal(attemptBudget("EXAMPLE_MAX_ATTEMPTS", 3), 4);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a handler's redelivery budget from the environment. CDK derives every
|
|
3
|
+
* `*_MAX_ATTEMPTS` var from the corresponding queue's own `MAX_RECEIVE_COUNT`
|
|
4
|
+
* (`infra/stacks/dev/stacks/remit-queue-stack.ts`, `deploy/vps/queues.json`)
|
|
5
|
+
* so a worker's idea of "last attempt" cannot drift from the redrive policy
|
|
6
|
+
* that actually dead-letters the record (issue #1270). SQS's
|
|
7
|
+
* `ApproximateReceiveCount` is the source of truth for how many times a record
|
|
8
|
+
* has been delivered; once it reaches this budget the current invocation is
|
|
9
|
+
* the last attempt, and the handler resolves exhaustion itself instead of
|
|
10
|
+
* letting the record dead-letter undiagnosed. The fallback keeps an
|
|
11
|
+
* environment that never injects the var — local dev, unit tests — behaving
|
|
12
|
+
* like production.
|
|
13
|
+
*/
|
|
14
|
+
export const attemptBudget = (
|
|
15
|
+
envName: string,
|
|
16
|
+
fallback: number,
|
|
17
|
+
processEnv: NodeJS.ProcessEnv = process.env,
|
|
18
|
+
): number => {
|
|
19
|
+
const raw = processEnv[envName];
|
|
20
|
+
if (!raw) return fallback;
|
|
21
|
+
const parsed = Number.parseInt(raw, 10);
|
|
22
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
23
|
+
return parsed;
|
|
24
|
+
};
|
package/src/poller.ts
CHANGED
|
@@ -231,7 +231,8 @@ const pollTarget = async (
|
|
|
231
231
|
};
|
|
232
232
|
|
|
233
233
|
/**
|
|
234
|
-
*
|
|
234
|
+
* Runs every target's poll loop until one of `signals` is received (default:
|
|
235
|
+
* SIGINT, SIGTERM). The signal aborts each loop's in-flight
|
|
235
236
|
* long-poll receive so an idle worker returns at once instead of waiting out
|
|
236
237
|
* the current 20s poll; a loop mid-handler finishes that handler first. Rejects
|
|
237
238
|
* (crashes the process) if any loop throws — a stuck poller should exit loudly,
|
package/src/producer.test.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import type { AddressInfo } from "node:net";
|
|
2
4
|
import { describe, it } from "node:test";
|
|
3
|
-
import type
|
|
5
|
+
import { ReceiveMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
|
|
4
6
|
import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
|
|
5
7
|
import { createQueueProducer, isLocalEndpoint } from "./producer.js";
|
|
6
8
|
|
|
@@ -148,3 +150,30 @@ describe("createQueueProducer", () => {
|
|
|
148
150
|
assert.equal((await remote.config.credentials()).accessKeyId, "real");
|
|
149
151
|
});
|
|
150
152
|
});
|
|
153
|
+
|
|
154
|
+
describe("createQueueProducer request timeout", () => {
|
|
155
|
+
it("fails a receive the queue server never answers", async () => {
|
|
156
|
+
const server = createServer(() => {});
|
|
157
|
+
await new Promise<void>((resolve) => {
|
|
158
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
159
|
+
});
|
|
160
|
+
const { port } = server.address() as AddressInfo;
|
|
161
|
+
const queueUrl = `http://127.0.0.1:${port}/000000000000/hung`;
|
|
162
|
+
const client = createQueueProducer({
|
|
163
|
+
queueUrl,
|
|
164
|
+
requestTimeoutMs: 100,
|
|
165
|
+
localCredentials: { accessKeyId: "test", secretAccessKey: "test" },
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
await assert.rejects(
|
|
170
|
+
() => client.send(new ReceiveMessageCommand({ QueueUrl: queueUrl })),
|
|
171
|
+
{ name: "TimeoutError" },
|
|
172
|
+
);
|
|
173
|
+
} finally {
|
|
174
|
+
client.destroy();
|
|
175
|
+
server.closeAllConnections();
|
|
176
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
package/src/producer.ts
CHANGED
|
@@ -12,8 +12,11 @@ export interface CreateQueueProducerOptions {
|
|
|
12
12
|
* sign with and a self-hosted stack has no credential chain to fall back on.
|
|
13
13
|
*/
|
|
14
14
|
readonly localCredentials?: SqsStaticCredentials;
|
|
15
|
+
readonly requestTimeoutMs?: number;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 45_000;
|
|
19
|
+
|
|
17
20
|
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
18
21
|
|
|
19
22
|
/**
|
|
@@ -59,8 +62,10 @@ export const createQueueProducer = (
|
|
|
59
62
|
options: CreateQueueProducerOptions,
|
|
60
63
|
): SQSClient => {
|
|
61
64
|
const { queueUrl, endpoint, env, localCredentials } = options;
|
|
65
|
+
const requestTimeout = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
62
66
|
const local = isLocalEndpoint(queueUrl);
|
|
63
67
|
return new SQSClient({
|
|
68
|
+
requestHandler: { requestTimeout, throwOnRequestTimeout: true },
|
|
64
69
|
endpoint: endpoint ?? (local ? new URL(queueUrl).origin : undefined),
|
|
65
70
|
...(local && { protocol: AwsQueryProtocol }),
|
|
66
71
|
credentials:
|