@friggframework/core 2.0.0--canary.578.3f8e78e.0 → 2.0.0--canary.579.312fe8b.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,52 +74,134 @@ class Requester extends Delegate {
48
74
 
49
75
  if (this.agent) options.agent = this.agent;
50
76
 
51
- let response;
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
+
90
+ // Timer must stay active through body consumption. node-fetch v2
91
+ // resolves the fetch() promise when headers arrive, not when the
92
+ // body is fully read — so a server that sends headers and then
93
+ // stalls the body would still hang parsedBody() or
94
+ // FetchError.create()'s response.text() call. We clear the timer
95
+ // only after the body is fully consumed (success path) or
96
+ // deliberately before each recursive retry so the new attempt
97
+ // starts with its own fresh timer.
98
+ let timerCleared = false;
99
+ const clearRequestTimer = () => {
100
+ if (!timerCleared && timeoutHandle) {
101
+ clearTimeout(timeoutHandle);
102
+ timerCleared = true;
103
+ }
104
+ };
105
+
52
106
  try {
53
- response = await this.fetch(encodedUrl, options);
54
- } catch (e) {
55
- if (e.code === 'ECONNRESET' && i < this.backOff.length) {
107
+ let response;
108
+ try {
109
+ response = await this.fetch(encodedUrl, fetchOptions);
110
+ } catch (e) {
111
+ // AbortController fires AbortError (name) / ETIMEDOUT-shaped
112
+ // errors (type on node-fetch) when we hit the timeout. No
113
+ // retry on timeout: a slow endpoint is a downstream problem,
114
+ // and each retry would wait another `timeoutMs` before giving
115
+ // up — amplifying the hang into a per-record multi-minute
116
+ // stall at batch scale.
117
+ const isTimeout =
118
+ e?.name === 'AbortError' || e?.type === 'aborted';
119
+ if (e?.code === 'ECONNRESET' && i < this.backOff.length) {
120
+ clearRequestTimer();
121
+ const delay = this.backOff[i] * 1000;
122
+ await new Promise((resolve) =>
123
+ setTimeout(resolve, delay)
124
+ );
125
+ return this._request(url, options, i + 1);
126
+ }
127
+ const fetchError = await FetchError.create({
128
+ resource: encodedUrl,
129
+ init: options,
130
+ responseBody: isTimeout
131
+ ? `Request timed out after ${timeoutMs}ms`
132
+ : e,
133
+ });
134
+ if (isTimeout) {
135
+ // Flag + machine-readable fields so callers can
136
+ // distinguish a timeout from a generic network error
137
+ // without parsing the message (which FetchError
138
+ // sanitizes outside of STAGE=dev).
139
+ fetchError.isTimeout = true;
140
+ fetchError.timeoutMs = timeoutMs;
141
+ }
142
+ throw fetchError;
143
+ }
144
+
145
+ const { status } = response;
146
+
147
+ // If the status is retriable and there are back off requests left, retry the request
148
+ if ((status === 429 || status >= 500) && i < this.backOff.length) {
149
+ clearRequestTimer();
56
150
  const delay = this.backOff[i] * 1000;
57
151
  await new Promise((resolve) => setTimeout(resolve, delay));
58
152
  return this._request(url, options, i + 1);
59
- }
60
- throw await FetchError.create({
61
- resource: encodedUrl,
62
- init: options,
63
- responseBody: e,
64
- });
65
- }
66
- const { status } = response;
67
-
68
- // If the status is retriable and there are back off requests left, retry the request
69
- if ((status === 429 || status >= 500) && i < this.backOff.length) {
70
- const delay = this.backOff[i] * 1000;
71
- await new Promise((resolve) => setTimeout(resolve, delay));
72
- return this._request(url, options, i + 1);
73
- } else if (status === 401) {
74
- if (!this.isRefreshable || this.refreshCount > 0) {
75
- await this.notify(this.DLGT_INVALID_AUTH);
76
- } else {
77
- this.refreshCount++;
78
- const refreshSucceeded = await this.refreshAuth();
79
- if (refreshSucceeded) {
80
- return this._request(url, options, i + 1);
153
+ } else if (status === 401) {
154
+ if (!this.isRefreshable || this.refreshCount > 0) {
155
+ await this.notify(this.DLGT_INVALID_AUTH);
156
+ } else {
157
+ this.refreshCount++;
158
+ const refreshSucceeded = await this.refreshAuth();
159
+ if (refreshSucceeded) {
160
+ clearRequestTimer();
161
+ return this._request(url, options, i + 1);
162
+ }
81
163
  }
82
164
  }
83
- }
84
165
 
85
- // If the error wasn't retried, throw.
86
- if (status >= 400) {
87
- throw await FetchError.create({
88
- resource: encodedUrl,
89
- init: options,
90
- response,
91
- });
166
+ // If the error wasn't retried, throw. FetchError.create reads
167
+ // the response body (response.text()) — timer must still be
168
+ // alive to catch a stalled body stream.
169
+ if (status >= 400) {
170
+ const fetchError = await FetchError.create({
171
+ resource: encodedUrl,
172
+ init: options,
173
+ response,
174
+ });
175
+ throw this._maybeFlagTimeoutDuringBodyRead(
176
+ fetchError,
177
+ timeoutMs
178
+ );
179
+ }
180
+
181
+ // parsedBody consumes the response body stream. If the server
182
+ // stalls mid-stream the timer (still armed) aborts it.
183
+ return options.returnFullRes
184
+ ? response
185
+ : await this.parsedBody(response);
186
+ } catch (e) {
187
+ // If the abort fired during body consumption, node-fetch emits
188
+ // the error as an AbortError on the body stream. Surface the
189
+ // same isTimeout flag callers use for header-phase timeouts.
190
+ throw this._maybeFlagTimeoutDuringBodyRead(e, timeoutMs);
191
+ } finally {
192
+ clearRequestTimer();
92
193
  }
194
+ }
93
195
 
94
- return options.returnFullRes
95
- ? response
96
- : await this.parsedBody(response);
196
+ _maybeFlagTimeoutDuringBodyRead(err, timeoutMs) {
197
+ if (!err || typeof err !== 'object') return err;
198
+ if (err.isTimeout) return err;
199
+ const isAbort =
200
+ err.name === 'AbortError' || err.type === 'aborted';
201
+ if (!isAbort) return err;
202
+ err.isTimeout = true;
203
+ err.timeoutMs = timeoutMs;
204
+ return err;
97
205
  }
98
206
 
99
207
  async _get(options) {
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.312fe8b.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.312fe8b.0",
42
+ "@friggframework/prettier-config": "2.0.0--canary.579.312fe8b.0",
43
+ "@friggframework/test": "2.0.0--canary.579.312fe8b.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": "312fe8be9f6afce4d78c1af5f13b03e0a5595cd6"
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