@friggframework/core 2.0.0--canary.578.3f8e78e.0 → 2.0.0--canary.579.4f65577.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/core/Worker.js CHANGED
@@ -20,45 +20,15 @@ class Worker {
20
20
  const records = get(params, 'Records');
21
21
  const batchItemFailures = [];
22
22
 
23
- console.log(
24
- `[Worker] run: processing ${records.length} record(s)`
25
- );
26
-
27
23
  for (const record of records) {
28
- // Log record entry with SQS-provided attributes useful for tracing
29
- // delivery history (ApproximateReceiveCount for retries, etc.).
30
- let parsedEvent;
31
- try {
32
- parsedEvent = JSON.parse(record.body)?.event;
33
- } catch {
34
- parsedEvent = undefined;
35
- }
36
- console.log(`[Worker] record begin`, {
37
- messageId: record.messageId,
38
- event: parsedEvent,
39
- receiveCount: record.attributes?.ApproximateReceiveCount,
40
- });
41
-
42
24
  try {
43
25
  const runParams = JSON.parse(record.body);
44
26
  this._validateParams(runParams);
45
27
  await this._run(runParams, context);
46
- console.log(`[Worker] record success`, {
47
- messageId: record.messageId,
48
- event: runParams?.event,
49
- });
50
28
  } catch (error) {
51
29
  if (error.isHaltError) {
52
30
  // HaltError means "discard this message, don't retry".
53
31
  // Treat as success so SQS deletes it from the queue.
54
- // Logged explicitly — silent discards made prod debugging
55
- // extremely hard; keep this visible.
56
- console.warn(`[Worker] record halted (discarded, no retry)`, {
57
- messageId: record.messageId,
58
- event: parsedEvent,
59
- reason: error.message,
60
- statusCode: error.statusCode,
61
- });
62
32
  continue;
63
33
  }
64
34
  console.error(`[Worker] Failed to process record ${record.messageId}:`, error);
@@ -66,12 +36,6 @@ class Worker {
66
36
  }
67
37
  }
68
38
 
69
- if (batchItemFailures.length > 0) {
70
- console.warn(
71
- `[Worker] run: returning ${batchItemFailures.length} batchItemFailure(s) of ${records.length}`
72
- );
73
- }
74
-
75
39
  return { batchItemFailures };
76
40
  }
77
41
 
@@ -5,45 +5,6 @@
5
5
  const { initDebugLog, flushDebugLog } = require('../logs');
6
6
  const { secretsToEnv } = require('./secrets-to-env');
7
7
 
8
- // Best-effort extraction of correlation identifiers from a Lambda event.
9
- // For SQS: pulls messageIds + parsed event/processId/integrationId from each
10
- // record body. For HTTP: pulls method+path. Never throws.
11
- const summarizeLambdaEvent = (event) => {
12
- if (!event) return {};
13
- if (Array.isArray(event.Records)) {
14
- return {
15
- source: 'sqs',
16
- records: event.Records.map((r) => {
17
- let parsed = {};
18
- try {
19
- const body = JSON.parse(r.body);
20
- parsed = {
21
- event: body?.event,
22
- processId: body?.data?.processId,
23
- integrationId: body?.data?.integrationId,
24
- };
25
- } catch {
26
- // ignore unparseable bodies
27
- }
28
- return {
29
- messageId: r.messageId,
30
- receiveCount: r.attributes?.ApproximateReceiveCount,
31
- ...parsed,
32
- };
33
- }),
34
- };
35
- }
36
- if (event.httpMethod || event.requestContext?.http) {
37
- return {
38
- source: 'http',
39
- method:
40
- event.httpMethod || event.requestContext?.http?.method,
41
- path: event.path || event.rawPath,
42
- };
43
- }
44
- return { source: 'other' };
45
- };
46
-
47
8
  const createHandler = (optionByName = {}) => {
48
9
  const {
49
10
  eventName = 'Event',
@@ -56,18 +17,7 @@ const createHandler = (optionByName = {}) => {
56
17
  }
57
18
 
58
19
  return async (event, context) => {
59
- const eventSummary = summarizeLambdaEvent(event);
60
-
61
20
  try {
62
- console.info(
63
- `[createHandler] ${eventName}: handler entry`,
64
- {
65
- eventName,
66
- awsRequestId: context?.awsRequestId,
67
- ...eventSummary,
68
- }
69
- );
70
-
71
21
  initDebugLog(eventName, event);
72
22
 
73
23
  const requestMethod = event.httpMethod;
@@ -112,21 +62,7 @@ const createHandler = (optionByName = {}) => {
112
62
  // Handle server-to-server responses.
113
63
 
114
64
  // Halt errors are logged but suceed and won't be retried.
115
- // Log explicitly — silent suppression here previously made stuck
116
- // messages invisible to observability tooling. Include
117
- // eventSummary so operators can correlate across concurrent
118
- // invocations (processId / messageIds / HTTP path).
119
65
  if (error.isHaltError === true) {
120
- console.warn(
121
- `[createHandler] ${eventName}: halt error suppressed (no retry)`,
122
- {
123
- eventName,
124
- errorName: error.name,
125
- errorMessage: error.message,
126
- statusCode: error.statusCode,
127
- ...eventSummary,
128
- }
129
- );
130
66
  return;
131
67
  }
132
68
 
@@ -141,17 +141,8 @@ const loadIntegrationForProcess = async (processId, integrationClass) => {
141
141
  };
142
142
 
143
143
  const createQueueWorker = (integrationClass) => {
144
- const integrationName = integrationClass.Definition.name;
145
-
146
144
  class QueueWorker extends Worker {
147
145
  async _run(params, context) {
148
- const logCtx = {
149
- integration: integrationName,
150
- event: params.event,
151
- processId: params.data?.processId,
152
- integrationId: params.data?.integrationId,
153
- };
154
-
155
146
  try {
156
147
  let integrationInstance;
157
148
 
@@ -159,46 +150,29 @@ const createQueueWorker = (integrationClass) => {
159
150
  // then integrationId (for ANY event type that needs hydration),
160
151
  // fallback to unhydrated instance
161
152
  if (params.data?.processId) {
162
- console.log(
163
- `[QueueWorker] hydrating by processId`,
164
- logCtx
165
- );
166
153
  integrationInstance = await loadIntegrationForProcess(
167
154
  params.data.processId,
168
155
  integrationClass
169
156
  );
170
- console.log(`[QueueWorker] hydrated`, {
171
- ...logCtx,
172
- integrationStatus: integrationInstance?.status,
173
- hydratedIntegrationId: integrationInstance?.id,
174
- });
175
157
  if (['DISABLED', 'ERROR'].includes(integrationInstance?.status)) {
176
158
  console.warn(
177
- `[${integrationName}] Integration for process ${params.data.processId} is ${integrationInstance.status}. Discarding ${params.event} message.`
159
+ `[${integrationClass.Definition.name}] Integration for process ${params.data.processId} is ${integrationInstance.status}. Discarding ${params.event} message.`
178
160
  );
179
161
  return;
180
162
  }
181
163
  } else if (params.data?.integrationId) {
182
- console.log(
183
- `[QueueWorker] hydrating by integrationId`,
184
- logCtx
185
- );
186
164
  integrationInstance = await loadIntegrationForWebhook(
187
165
  params.data.integrationId
188
166
  );
189
167
  if (!integrationInstance) {
190
168
  console.warn(
191
- `[${integrationName}] Integration ${params.data.integrationId} no longer exists. Discarding ${params.event} message.`
169
+ `[${integrationClass.Definition.name}] Integration ${params.data.integrationId} no longer exists. Discarding ${params.event} message.`
192
170
  );
193
171
  return;
194
172
  }
195
- console.log(`[QueueWorker] hydrated`, {
196
- ...logCtx,
197
- integrationStatus: integrationInstance?.status,
198
- });
199
173
  if (['DISABLED', 'ERROR'].includes(integrationInstance.status)) {
200
174
  console.warn(
201
- `[${integrationName}] Integration ${params.data.integrationId} is ${integrationInstance.status}. Discarding ${params.event} message.`
175
+ `[${integrationClass.Definition.name}] Integration ${params.data.integrationId} is ${integrationInstance.status}. Discarding ${params.event} message.`
202
176
  );
203
177
  return;
204
178
  }
@@ -207,10 +181,6 @@ const createQueueWorker = (integrationClass) => {
207
181
  // There will be cases where we need to use helpers that the api modules can export.
208
182
  // Like for HubSpot, the answer is to do a reverse lookup for the integration by the entity external ID (HubSpot Portal ID),
209
183
  // and then you'll have the integration ID available to hydrate from.
210
- console.log(
211
- `[QueueWorker] no processId/integrationId — running dry instance`,
212
- logCtx
213
- );
214
184
  integrationInstance = new integrationClass();
215
185
  }
216
186
 
@@ -218,17 +188,14 @@ const createQueueWorker = (integrationClass) => {
218
188
  integrationInstance
219
189
  );
220
190
 
221
- console.log(`[QueueWorker] dispatching ${params.event}`, logCtx);
222
- const result = await dispatcher.dispatchJob({
191
+ return await dispatcher.dispatchJob({
223
192
  event: params.event,
224
193
  data: params.data,
225
194
  context: context,
226
195
  });
227
- console.log(`[QueueWorker] ${params.event} dispatched ok`, logCtx);
228
- return result;
229
196
  } catch (error) {
230
197
  console.error(
231
- `Error in ${params.event} for ${integrationName}:`,
198
+ `Error in ${params.event} for ${integrationClass.Definition.name}:`,
232
199
  error
233
200
  );
234
201
 
@@ -240,12 +207,7 @@ const createQueueWorker = (integrationClass) => {
240
207
  if (status && status >= 400 && status < 500 && status !== 408 && status !== 429) {
241
208
  error.isHaltError = true;
242
209
  console.warn(
243
- `[${integrationName}] Permanent ${status} error for ${params.event} — message will be discarded (no retry)`,
244
- {
245
- ...logCtx,
246
- errorName: error.name,
247
- errorMessage: error.message,
248
- }
210
+ `[${integrationClass.Definition.name}] Permanent ${status} error for ${params.event} — message will be discarded (no retry)`
249
211
  );
250
212
  }
251
213
 
@@ -3,6 +3,8 @@ const { Delegate } = require('../../core');
3
3
  const { FetchError } = require('../../errors');
4
4
  const { get } = require('../../assertions');
5
5
 
6
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
7
+
6
8
  class Requester extends Delegate {
7
9
  constructor(params) {
8
10
  super(params);
@@ -13,6 +15,30 @@ class Requester extends Delegate {
13
15
  this.delegateTypes.push(this.DLGT_INVALID_AUTH);
14
16
  this.agent = get(params, 'agent', null);
15
17
 
18
+ // Per-attempt HTTP timeout. Without this the framework called fetch()
19
+ // with no AbortController and no timeout — a silently-hung TCP
20
+ // connection (server accepts but never responds) blocked the calling
21
+ // promise forever, cascading into stalled batches, stalled syncs,
22
+ // and worker-lambda timeouts.
23
+ //
24
+ // Configuration precedence:
25
+ // 1. Instance param: new Requester({ requestTimeoutMs: 30_000 })
26
+ // 2. Class static: static requestTimeoutMs = 30_000
27
+ // 3. Default: DEFAULT_REQUEST_TIMEOUT_MS (60s)
28
+ //
29
+ // Pass 0 (or null) to disable the timeout entirely — reserved for
30
+ // test doubles and documented long-running endpoints.
31
+ // Intentionally NOT using `get(params, ...)` here — the Frigg
32
+ // `get` helper throws RequiredPropertyError if the key is missing
33
+ // and no default is provided, which would collide with the fall-
34
+ // through to the class-level static override.
35
+ const instanceTimeout = params?.requestTimeoutMs;
36
+ this.requestTimeoutMs =
37
+ instanceTimeout !== undefined && instanceTimeout !== null
38
+ ? instanceTimeout
39
+ : this.constructor.requestTimeoutMs ??
40
+ DEFAULT_REQUEST_TIMEOUT_MS;
41
+
16
42
  // Allow passing in the fetch function
17
43
  // Instance methods can use this.fetch without differentiating
18
44
  this.fetch = get(params, 'fetch', fetch);
@@ -48,20 +74,54 @@ class Requester extends Delegate {
48
74
 
49
75
  if (this.agent) options.agent = this.agent;
50
76
 
77
+ // Per-attempt timeout — fresh AbortController per call so the retry
78
+ // recursion (with its own backoff sleeps) always gets a clean
79
+ // signal. Timer is cleared in the finally block regardless of
80
+ // outcome.
81
+ const timeoutMs = this.requestTimeoutMs;
82
+ const controller = timeoutMs > 0 ? new AbortController() : null;
83
+ const timeoutHandle = controller
84
+ ? setTimeout(() => controller.abort(), timeoutMs)
85
+ : null;
86
+ const fetchOptions = controller
87
+ ? { ...options, signal: controller.signal }
88
+ : options;
89
+
51
90
  let response;
52
91
  try {
53
- response = await this.fetch(encodedUrl, options);
92
+ response = await this.fetch(encodedUrl, fetchOptions);
54
93
  } catch (e) {
55
- if (e.code === 'ECONNRESET' && i < this.backOff.length) {
94
+ // AbortController fires AbortError (name) / ETIMEDOUT-shaped
95
+ // errors (type on node-fetch) when we hit the timeout. No
96
+ // retry on timeout: a slow endpoint is a downstream problem,
97
+ // and each retry would wait another `timeoutMs` before giving
98
+ // up — amplifying the hang into a per-record multi-minute
99
+ // stall at batch scale.
100
+ const isTimeout =
101
+ e?.name === 'AbortError' || e?.type === 'aborted';
102
+ if (e?.code === 'ECONNRESET' && i < this.backOff.length) {
56
103
  const delay = this.backOff[i] * 1000;
57
104
  await new Promise((resolve) => setTimeout(resolve, delay));
58
105
  return this._request(url, options, i + 1);
59
106
  }
60
- throw await FetchError.create({
107
+ const fetchError = await FetchError.create({
61
108
  resource: encodedUrl,
62
109
  init: options,
63
- responseBody: e,
110
+ responseBody: isTimeout
111
+ ? `Request timed out after ${timeoutMs}ms`
112
+ : e,
64
113
  });
114
+ if (isTimeout) {
115
+ // Flag + machine-readable fields so callers can
116
+ // distinguish a timeout from a generic network error
117
+ // without parsing the message (which FetchError
118
+ // sanitizes outside of STAGE=dev).
119
+ fetchError.isTimeout = true;
120
+ fetchError.timeoutMs = timeoutMs;
121
+ }
122
+ throw fetchError;
123
+ } finally {
124
+ if (timeoutHandle) clearTimeout(timeoutHandle);
65
125
  }
66
126
  const { status } = response;
67
127
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.578.3f8e78e.0",
4
+ "version": "2.0.0--canary.579.4f65577.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -38,9 +38,9 @@
38
38
  }
39
39
  },
40
40
  "devDependencies": {
41
- "@friggframework/eslint-config": "2.0.0--canary.578.3f8e78e.0",
42
- "@friggframework/prettier-config": "2.0.0--canary.578.3f8e78e.0",
43
- "@friggframework/test": "2.0.0--canary.578.3f8e78e.0",
41
+ "@friggframework/eslint-config": "2.0.0--canary.579.4f65577.0",
42
+ "@friggframework/prettier-config": "2.0.0--canary.579.4f65577.0",
43
+ "@friggframework/test": "2.0.0--canary.579.4f65577.0",
44
44
  "@prisma/client": "^6.17.0",
45
45
  "@types/lodash": "4.17.15",
46
46
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -80,5 +80,5 @@
80
80
  "publishConfig": {
81
81
  "access": "public"
82
82
  },
83
- "gitHead": "3f8e78e2186c5d793aeffe803a2687ea0644fba1"
83
+ "gitHead": "4f65577ef61f82fc02d25d22a92ffa851c179676"
84
84
  }
@@ -18,88 +18,13 @@ const awsConfigOptions = () => {
18
18
 
19
19
  const sqs = new SQSClient(awsConfigOptions());
20
20
 
21
- // Best-effort extraction of the logical event/processId/integrationId from a
22
- // JSON message body. Used only for log correlation — never throws.
23
- const summarizeMessageBody = (bodyStr) => {
24
- try {
25
- const parsed = JSON.parse(bodyStr);
26
- return {
27
- event: parsed?.event,
28
- processId: parsed?.data?.processId,
29
- integrationId: parsed?.data?.integrationId,
30
- };
31
- } catch {
32
- return {};
33
- }
34
- };
35
-
36
- // Inspect SendMessageBatchResult for partial failures and log them.
37
- // AWS SendMessageBatch can succeed at the HTTP level while individual entries
38
- // are rejected (KMS errors, per-entry throttling, service errors). Callers that
39
- // don't inspect result.Failed silently lose those messages. This logs the
40
- // details — including the logical event/processId of the failed entry — so
41
- // the loss is visible and correlatable in CloudWatch.
42
- const inspectBatchResult = (result, queueUrl, buffer) => {
43
- const bufferSize = buffer.length;
44
- const failedCount = result?.Failed?.length ?? 0;
45
- const successCount = result?.Successful?.length ?? 0;
46
-
47
- // Index buffer by Id so we can attach event/processId to failures.
48
- const bufferById = new Map(buffer.map((b) => [b.Id, b]));
49
-
50
- if (failedCount > 0) {
51
- console.error(
52
- `[QueuerUtil] SendMessageBatch partial failure: ${failedCount}/${bufferSize} failed`,
53
- {
54
- queueUrl,
55
- bufferSize,
56
- successCount,
57
- failedCount,
58
- failed: result.Failed.map((f) => {
59
- const bufEntry = bufferById.get(f.Id);
60
- const summary = bufEntry
61
- ? summarizeMessageBody(bufEntry.MessageBody)
62
- : {};
63
- return {
64
- Id: f.Id,
65
- Code: f.Code,
66
- SenderFault: f.SenderFault,
67
- Message: f.Message,
68
- ...summary,
69
- };
70
- }),
71
- }
72
- );
73
- } else if (successCount > 0) {
74
- // Include a compact per-entry summary so operators can correlate
75
- // "which send contained which logical message" during incident triage.
76
- const entries = result.Successful.map((s) => {
77
- const bufEntry = bufferById.get(s.Id);
78
- const summary = bufEntry
79
- ? summarizeMessageBody(bufEntry.MessageBody)
80
- : {};
81
- return { MessageId: s.MessageId, ...summary };
82
- });
83
- console.log(
84
- `[QueuerUtil] SendMessageBatch ok: ${successCount}/${bufferSize} to ${queueUrl}`,
85
- { entries }
86
- );
87
- }
88
-
89
- return result;
90
- };
91
-
92
21
  const QueuerUtil = {
93
22
  send: async (message, queueUrl) => {
94
23
  const command = new SendMessageCommand({
95
24
  MessageBody: JSON.stringify(message),
96
25
  QueueUrl: queueUrl,
97
26
  });
98
- const result = await sqs.send(command);
99
- console.log(
100
- `[QueuerUtil] SendMessage ok: MessageId=${result?.MessageId} to ${queueUrl}`
101
- );
102
- return result;
27
+ return sqs.send(command);
103
28
  },
104
29
 
105
30
  batchSend: async (entries = [], queueUrl) => {
@@ -117,8 +42,7 @@ const QueuerUtil = {
117
42
  Entries: buffer,
118
43
  QueueUrl: queueUrl,
119
44
  });
120
- const result = await sqs.send(command);
121
- inspectBatchResult(result, queueUrl, buffer);
45
+ await sqs.send(command);
122
46
  // Purge the buffer
123
47
  buffer.splice(0, buffer.length);
124
48
  }
@@ -130,8 +54,7 @@ const QueuerUtil = {
130
54
  Entries: buffer,
131
55
  QueueUrl: queueUrl,
132
56
  });
133
- const result = await sqs.send(command);
134
- return inspectBatchResult(result, queueUrl, buffer);
57
+ return sqs.send(command);
135
58
  }
136
59
 
137
60
  // If we're exact... just return an empty object for now