@friggframework/core 2.0.0--canary.656.4fb07b4.0 → 2.0.0--canary.658.164d92b.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/CLAUDE.md CHANGED
@@ -530,6 +530,50 @@ if (!userId) {
530
530
  }
531
531
  ```
532
532
 
533
+ **Rate-limit message** (ADR-049): when a wait is longer than the in-process cap
534
+ (`maxInProcessWaitMs`, default 5 min), the Requester tells its delegate
535
+ (`RATE_LIMITED`, with the `RateLimitError`) and then throws. A shorter wait
536
+ that throws sends no notification. The notification takes the path of
537
+ `CREDENTIAL_INVALIDATED`: Requester, `Module`, `IntegrationBase`.
538
+
539
+ - The `Module` sends `{ moduleName, reason, retryAt, policy, statusCode, links }`.
540
+ `links` is `static rateLimit.userHints[reason].links`, or `[]`. The message,
541
+ url, body and headers of the error are never sent: the message reaches end
542
+ users.
543
+ - `IntegrationBase` writes one `WARN` (`integration.<name>.rate_limited`), and
544
+ `RecordRateLimitMessage` appends one item to the stored `warnings`. It
545
+ changes no status. A rate limit is not an `ERROR`, and an `ERROR` status
546
+ makes the queue worker drop every webhook.
547
+ - A report for the same module whose reset time is within 60 s of a stored one
548
+ is skipped. The check reads the stored `warnings` with
549
+ `findIntegrationMessages`, not `this.messages`, which is per instance and
550
+ empty on Postgres. It is a read then a write, so workers that report at the
551
+ same instant can still store a few copies.
552
+ - Every hop is best effort. A failure is logged, and the caller still gets the
553
+ `RateLimitError`.
554
+
555
+ ```javascript
556
+ {
557
+ title: 'Rate limit reached',
558
+ message: 'The hubspot API rate limit was reached and resets at 2026-09-28 14:31 UTC.',
559
+ timestamp: 1790000000000,
560
+ code: 'RATE_LIMITED',
561
+ module: 'hubspot',
562
+ reason: 'daily',
563
+ retryAt: '2026-09-28T14:30:15.000Z',
564
+ actions: [
565
+ { type: 'RETRY_WHEN_READY' },
566
+ { type: 'LINK', label: 'API usage limits', url: 'https://example.com/limits' },
567
+ ],
568
+ }
569
+ ```
570
+
571
+ The text is fixed. It holds the module name and the reset time in UTC, rounded
572
+ up to the minute, and nothing from the request.
573
+
574
+ Integration messages (the item form, `findIntegrationMessages`, the newest 50
575
+ per type): see "Integration Messages" in `docs/guides/INTEGRATION-PATTERNS.md`.
576
+
533
577
  ### 9. Logging System (`/logs`)
534
578
 
535
579
  **Purpose**: One redacted JSON record per line to stdout (ADR-048). See `docs/guides/LOGGING.md`.
@@ -17,6 +17,9 @@ const {
17
17
  const {
18
18
  UpdateIntegrationConfig,
19
19
  } = require('./use-cases/update-integration-config');
20
+ const {
21
+ RecordRateLimitMessage,
22
+ } = require('./use-cases/record-rate-limit-message');
20
23
  const { validateExtensionBinding } = require('./extension');
21
24
  const { getTelemetry } = require('../telemetry/telemetry-runtime');
22
25
  const { instrumentHandler } = require('../telemetry/instrument-handler');
@@ -59,6 +62,9 @@ class IntegrationBase {
59
62
  updateIntegrationConfig = new UpdateIntegrationConfig({
60
63
  integrationRepository: this.integrationRepository,
61
64
  });
65
+ recordRateLimitMessage = new RecordRateLimitMessage({
66
+ integrationRepository: this.integrationRepository,
67
+ });
62
68
 
63
69
  // this.telemetry.count('records.synced', n, { entity: 'contact' })
64
70
  telemetry = bindTelemetryContext(getTelemetry(), () =>
@@ -908,6 +914,16 @@ class IntegrationBase {
908
914
  });
909
915
  await this.persistStatus('ENABLED');
910
916
  }
917
+
918
+ if (delegateString === 'RATE_LIMITED') {
919
+ this.logger.warn('Module reported a rate limit', {
920
+ eventName: `${this.logger.name}.rate_limited`,
921
+ moduleName: object?.moduleName,
922
+ reason: object?.reason,
923
+ retryAt: object?.retryAt,
924
+ });
925
+ await this.recordRateLimitMessage.execute(this.id, object);
926
+ }
911
927
  }
912
928
 
913
929
  /**
@@ -14,6 +14,7 @@ const {
14
14
  IntegrationRepositoryInterface,
15
15
  } = require('./integration-repository-interface');
16
16
  const { validateConfigPatch } = require('./config-patch-shared');
17
+ const { messagesOfType } = require('./message-item-shared');
17
18
 
18
19
  class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
19
20
  constructor() {
@@ -94,9 +95,8 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
94
95
  async updateIntegrationMessages(
95
96
  integrationId,
96
97
  messageType,
97
- messageTitle,
98
- messageBody,
99
- messageTimestamp
98
+ item,
99
+ { keepLast }
100
100
  ) {
101
101
  const objectId = toObjectId(integrationId);
102
102
  if (!objectId) {
@@ -107,13 +107,12 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
107
107
  throw new Error(`Integration ${integrationId} not found`);
108
108
  }
109
109
  const messages = this._extractMessages(existing);
110
- const list = Array.isArray(messages[messageType]) ? [...messages[messageType]] : [];
111
- list.push({
112
- title: messageTitle ?? null,
113
- message: messageBody,
114
- timestamp: messageTimestamp,
115
- });
116
- const updatedMessages = { ...messages, [messageType]: list };
110
+ const list = [...messagesOfType(messages, messageType)];
111
+ list.push({ ...item, title: item.title ?? null });
112
+ const updatedMessages = {
113
+ ...messages,
114
+ [messageType]: list.slice(-keepLast),
115
+ };
117
116
  await updateOne(
118
117
  this.prisma,
119
118
  'Integration',
@@ -132,6 +131,20 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
132
131
  return true;
133
132
  }
134
133
 
134
+ async findIntegrationMessages(integrationId, messageType) {
135
+ const objectId = toObjectId(integrationId);
136
+ if (!objectId) {
137
+ throw new Error(`Integration ${integrationId} not found`);
138
+ }
139
+ const existing = await findOne(this.prisma, 'Integration', {
140
+ _id: objectId,
141
+ });
142
+ if (!existing) {
143
+ throw new Error(`Integration ${integrationId} not found`);
144
+ }
145
+ return messagesOfType(this._extractMessages(existing), messageType);
146
+ }
147
+
135
148
  async createIntegration(entities, userId, config) {
136
149
  const now = new Date();
137
150
  const document = {
@@ -97,26 +97,40 @@ class IntegrationRepositoryInterface {
97
97
  }
98
98
 
99
99
  /**
100
- * Update integration messages
100
+ * Append a message item to the stored messages of one type
101
101
  *
102
102
  * @param {string|number} integrationId - Integration ID
103
103
  * @param {string} messageType - Type of message (errors, warnings, info, logs)
104
- * @param {string} messageTitle - Message title
105
- * @param {string} messageBody - Message body
106
- * @param {Date} messageTimestamp - Message timestamp
104
+ * @param {Object} item - Message item, stored as it is
105
+ * @param {Object} options
106
+ * @param {number} options.keepLast - How many of the newest items of the type to keep
107
107
  * @returns {Promise<boolean>} Success indicator
108
108
  * @abstract
109
109
  */
110
110
  async updateIntegrationMessages(
111
111
  integrationId,
112
112
  messageType,
113
- messageTitle,
114
- messageBody,
115
- messageTimestamp
113
+ item,
114
+ { keepLast }
116
115
  ) {
117
116
  throw new Error('Method updateIntegrationMessages must be implemented by subclass');
118
117
  }
119
118
 
119
+ /**
120
+ * Find the stored messages of one type. Reads the stored items, not the
121
+ * `messages` of an integration record, which some adapters do not fill.
122
+ *
123
+ * @param {string|number} integrationId - Integration ID
124
+ * @param {string} messageType - Type of message (errors, warnings, info, logs)
125
+ * @returns {Promise<Array>} The stored message items of that type, oldest first
126
+ * @abstract
127
+ */
128
+ async findIntegrationMessages(integrationId, messageType) {
129
+ throw new Error(
130
+ 'Method findIntegrationMessages must be implemented by subclass'
131
+ );
132
+ }
133
+
120
134
  /**
121
135
  * Create a new integration
122
136
  *
@@ -3,6 +3,7 @@ const {
3
3
  IntegrationRepositoryInterface,
4
4
  } = require('./integration-repository-interface');
5
5
  const { validateConfigPatch } = require('./config-patch-shared');
6
+ const { messagesOfType } = require('./message-item-shared');
6
7
 
7
8
  /**
8
9
  * MongoDB Integration Repository Adapter
@@ -229,17 +230,16 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
229
230
  *
230
231
  * @param {string} integrationId - Integration ID
231
232
  * @param {string} messageType - Type of message (errors, warnings, info, logs)
232
- * @param {string} messageTitle - Message title
233
- * @param {string} messageBody - Message body
234
- * @param {Date} messageTimestamp - Message timestamp
233
+ * @param {Object} item - Message item, stored as it is
234
+ * @param {Object} options
235
+ * @param {number} options.keepLast - How many of the newest items of the type to keep
235
236
  * @returns {Promise<boolean>} Success indicator
236
237
  */
237
238
  async updateIntegrationMessages(
238
239
  integrationId,
239
240
  messageType,
240
- messageTitle,
241
- messageBody,
242
- messageTimestamp
241
+ item,
242
+ { keepLast }
243
243
  ) {
244
244
  // Get current integration
245
245
  const integration = await this.prisma.integration.findUnique({
@@ -251,29 +251,42 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
251
251
  }
252
252
 
253
253
  // Parse existing messages (JSON field)
254
- const messages = integration.messages || {};
255
- const messageArray = Array.isArray(messages[messageType])
256
- ? messages[messageType]
257
- : [];
254
+ const messageArray = messagesOfType(integration, messageType);
258
255
 
259
256
  // Add new message
260
- messageArray.push({
261
- title: messageTitle,
262
- message: messageBody,
263
- timestamp: messageTimestamp,
264
- });
257
+ messageArray.push(item);
265
258
 
266
259
  // Update messages
267
260
  await this.prisma.integration.update({
268
261
  where: { id: integrationId },
269
262
  data: {
270
- [messageType]: messageArray,
263
+ [messageType]: messageArray.slice(-keepLast),
271
264
  },
272
265
  });
273
266
 
274
267
  return true; // Mongoose compatibility
275
268
  }
276
269
 
270
+ /**
271
+ * Find the stored messages of one type
272
+ *
273
+ * @param {string} integrationId - Integration ID
274
+ * @param {string} messageType - Type of message (errors, warnings, info, logs)
275
+ * @returns {Promise<Array>} The stored message items of that type, oldest first
276
+ */
277
+ async findIntegrationMessages(integrationId, messageType) {
278
+ const integration = await this.prisma.integration.findUnique({
279
+ where: { id: integrationId },
280
+ select: { [messageType]: true },
281
+ });
282
+
283
+ if (!integration) {
284
+ throw new Error(`Integration ${integrationId} not found`);
285
+ }
286
+
287
+ return messagesOfType(integration, messageType);
288
+ }
289
+
277
290
  /**
278
291
  * Find all integrations whose entity set includes the given entity ID.
279
292
  *
@@ -3,6 +3,7 @@ const {
3
3
  IntegrationRepositoryInterface,
4
4
  } = require('./integration-repository-interface');
5
5
  const { validateConfigPatch } = require('./config-patch-shared');
6
+ const { messagesOfType } = require('./message-item-shared');
6
7
  const { strictIntId } = require('./report-id');
7
8
 
8
9
  /**
@@ -273,17 +274,16 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
273
274
  *
274
275
  * @param {string} integrationId - Integration ID (string from application layer)
275
276
  * @param {string} messageType - Type of message (errors, warnings, info, logs)
276
- * @param {string} messageTitle - Message title
277
- * @param {string} messageBody - Message body
278
- * @param {Date} messageTimestamp - Message timestamp
277
+ * @param {Object} item - Message item, stored as it is
278
+ * @param {Object} options
279
+ * @param {number} options.keepLast - How many of the newest items of the type to keep
279
280
  * @returns {Promise<boolean>} Success indicator
280
281
  */
281
282
  async updateIntegrationMessages(
282
283
  integrationId,
283
284
  messageType,
284
- messageTitle,
285
- messageBody,
286
- messageTimestamp
285
+ item,
286
+ { keepLast }
287
287
  ) {
288
288
  const intId = this._convertId(integrationId);
289
289
 
@@ -297,29 +297,43 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
297
297
  }
298
298
 
299
299
  // Parse existing messages (JSON field)
300
- const messages = integration.messages || {};
301
- const messageArray = Array.isArray(messages[messageType])
302
- ? messages[messageType]
303
- : [];
300
+ const messageArray = messagesOfType(integration, messageType);
304
301
 
305
302
  // Add new message
306
- messageArray.push({
307
- title: messageTitle,
308
- message: messageBody,
309
- timestamp: messageTimestamp,
310
- });
303
+ messageArray.push(item);
311
304
 
312
305
  // Update messages
313
306
  await this.prisma.integration.update({
314
307
  where: { id: intId },
315
308
  data: {
316
- [messageType]: messageArray,
309
+ [messageType]: messageArray.slice(-keepLast),
317
310
  },
318
311
  });
319
312
 
320
313
  return true; // Mongoose compatibility
321
314
  }
322
315
 
316
+ /**
317
+ * Find the stored messages of one type
318
+ *
319
+ * @param {string} integrationId - Integration ID (string from application layer)
320
+ * @param {string} messageType - Type of message (errors, warnings, info, logs)
321
+ * @returns {Promise<Array>} The stored message items of that type, oldest first
322
+ */
323
+ async findIntegrationMessages(integrationId, messageType) {
324
+ const intId = this._convertId(integrationId);
325
+ const integration = await this.prisma.integration.findUnique({
326
+ where: { id: intId },
327
+ select: { [messageType]: true },
328
+ });
329
+
330
+ if (!integration) {
331
+ throw new Error(`Integration ${integrationId} not found`);
332
+ }
333
+
334
+ return messagesOfType(integration, messageType);
335
+ }
336
+
323
337
  /**
324
338
  * Create a new integration
325
339
  *
@@ -0,0 +1,4 @@
1
+ const messagesOfType = (record, messageType) =>
2
+ Array.isArray(record[messageType]) ? record[messageType] : [];
3
+
4
+ module.exports = { messagesOfType };
@@ -0,0 +1,24 @@
1
+ const copy = (value) => JSON.parse(JSON.stringify(value));
2
+
3
+ function withMessageRow(repo, id, stored = {}) {
4
+ const row = {
5
+ id,
6
+ errors: [],
7
+ warnings: [],
8
+ info: [],
9
+ logs: [],
10
+ ...stored,
11
+ };
12
+ repo.prisma = {
13
+ integration: {
14
+ findUnique: jest.fn(async () => copy(row)),
15
+ update: jest.fn(async ({ data }) => {
16
+ Object.assign(row, copy(data));
17
+ return copy(row);
18
+ }),
19
+ },
20
+ };
21
+ return { repo, row };
22
+ }
23
+
24
+ module.exports = { withMessageRow };
@@ -72,18 +72,31 @@ class TestIntegrationRepository {
72
72
  return results;
73
73
  }
74
74
 
75
- async updateIntegrationMessages(id, type, title, body, timestamp) {
75
+ async updateIntegrationMessages(id, type, item, { keepLast }) {
76
76
  const rec = this.store.get(id);
77
77
  if (!rec) {
78
78
  this.operationHistory.push({ operation: 'updateMessages', id, success: false });
79
79
  return false;
80
80
  }
81
81
  if (!rec.messages[type]) rec.messages[type] = [];
82
- rec.messages[type].push({ title, message: body, timestamp });
82
+ rec.messages[type].push(item);
83
+ rec.messages[type] = rec.messages[type].slice(-keepLast);
83
84
  this.operationHistory.push({ operation: 'updateMessages', id, type, success: true });
84
85
  return true;
85
86
  }
86
87
 
88
+ async findIntegrationMessages(id, type) {
89
+ const rec = this.store.get(id);
90
+ this.operationHistory.push({
91
+ operation: 'findMessages',
92
+ id,
93
+ type,
94
+ found: !!rec,
95
+ });
96
+ if (!rec) throw new Error(`Integration ${id} not found`);
97
+ return [...(rec.messages[type] ?? [])];
98
+ }
99
+
87
100
  async updateIntegrationConfig(id, config) {
88
101
  if (config === null || config === undefined) {
89
102
  throw new Error('Config parameter is required');
@@ -0,0 +1,110 @@
1
+ const { getLogger } = require('../../logs');
2
+ const { MAX_MESSAGES_PER_TYPE } = require('./update-integration-messages');
3
+
4
+ const log = getLogger('frigg.integrations');
5
+
6
+ const RATE_LIMITED_MESSAGE_CODE = 'RATE_LIMITED';
7
+ const SAME_LIMIT_WINDOW_MS = 60_000;
8
+ const MINUTE_MS = 60_000;
9
+
10
+ const isNonEmptyString = (value) => typeof value === 'string' && value !== '';
11
+
12
+ const isValidTime = (value) =>
13
+ value !== null &&
14
+ value !== undefined &&
15
+ !Number.isNaN(new Date(value).getTime());
16
+
17
+ const isLink = (link) =>
18
+ typeof link?.label === 'string' && typeof link?.url === 'string';
19
+
20
+ const linkActions = (links) =>
21
+ (Array.isArray(links) ? links : [])
22
+ .filter(isLink)
23
+ .map(({ label, url }) => ({ type: 'LINK', label, url }));
24
+
25
+ function formatResetTime(date) {
26
+ const minute = new Date(Math.ceil(date.getTime() / MINUTE_MS) * MINUTE_MS);
27
+ return `${minute.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
28
+ }
29
+
30
+ /**
31
+ * @class RecordRateLimitMessage
32
+ */
33
+ class RecordRateLimitMessage {
34
+ /**
35
+ * @param {Object} params - Configuration parameters.
36
+ * @param {import('../repositories/integration-repository-interface').IntegrationRepositoryInterface} params.integrationRepository - Repository for integration data operations.
37
+ */
38
+ constructor({ integrationRepository }) {
39
+ this.integrationRepository = integrationRepository;
40
+ }
41
+
42
+ /**
43
+ * @async
44
+ * @param {string} integrationId - ID of the integration to warn on.
45
+ * @param {Object} payload - The RATE_LIMITED payload of the Module.
46
+ * @param {string} payload.moduleName
47
+ * @param {string} payload.reason
48
+ * @param {Date|string|number} payload.retryAt
49
+ * @param {Array<{label: string, url: string}>} [payload.links]
50
+ */
51
+ async execute(integrationId, payload) {
52
+ const { moduleName, reason, retryAt, links } = payload ?? {};
53
+ if (!isNonEmptyString(moduleName) || !isValidTime(retryAt)) return;
54
+
55
+ try {
56
+ await this._recordUnlessStored(integrationId, {
57
+ moduleName,
58
+ reason,
59
+ resetAt: new Date(retryAt),
60
+ links,
61
+ });
62
+ } catch (error) {
63
+ log.warn('Failed to record rate limit message', {
64
+ eventName:
65
+ 'frigg.integrations.rate_limit_message_record_failed',
66
+ integrationId,
67
+ moduleName,
68
+ error,
69
+ });
70
+ }
71
+ }
72
+
73
+ async _recordUnlessStored(
74
+ integrationId,
75
+ { moduleName, reason, resetAt, links }
76
+ ) {
77
+ const warnings =
78
+ await this.integrationRepository.findIntegrationMessages(
79
+ integrationId,
80
+ 'warnings'
81
+ );
82
+ const alreadyRecorded = warnings.some(
83
+ (warning) =>
84
+ warning?.code === RATE_LIMITED_MESSAGE_CODE &&
85
+ warning.module === moduleName &&
86
+ Math.abs(Date.parse(warning.retryAt) - resetAt.getTime()) <=
87
+ SAME_LIMIT_WINDOW_MS
88
+ );
89
+ if (alreadyRecorded) return;
90
+
91
+ const resetsAt = formatResetTime(resetAt);
92
+ await this.integrationRepository.updateIntegrationMessages(
93
+ integrationId,
94
+ 'warnings',
95
+ {
96
+ title: 'Rate limit reached',
97
+ message: `The ${moduleName} API rate limit was reached and resets at ${resetsAt}.`,
98
+ timestamp: Date.now(),
99
+ code: RATE_LIMITED_MESSAGE_CODE,
100
+ module: moduleName,
101
+ reason,
102
+ retryAt: resetAt.toISOString(),
103
+ actions: [{ type: 'RETRY_WHEN_READY' }, ...linkActions(links)],
104
+ },
105
+ { keepLast: MAX_MESSAGES_PER_TYPE }
106
+ );
107
+ }
108
+ }
109
+
110
+ module.exports = { RecordRateLimitMessage };
@@ -1,3 +1,17 @@
1
+ const MAX_MESSAGES_PER_TYPE = 50;
2
+
3
+ const isPlainObject = (value) =>
4
+ value !== null && typeof value === 'object' && !Array.isArray(value);
5
+
6
+ function toMessageItem(titleOrItem, messageBody, messageTimestamp) {
7
+ if (isPlainObject(titleOrItem)) return { ...titleOrItem };
8
+ return {
9
+ title: titleOrItem,
10
+ message: messageBody,
11
+ timestamp: messageTimestamp,
12
+ };
13
+ }
14
+
1
15
  /**
2
16
  * Use case for updating messages associated with an integration.
3
17
  * @class UpdateIntegrationMessages
@@ -13,19 +27,21 @@ class UpdateIntegrationMessages {
13
27
  }
14
28
 
15
29
  /**
16
- * Executes the integration messages update.
30
+ * Executes the integration messages update. Pass the positional form
31
+ * (title, body, timestamp) or one message item object; the keys of an item
32
+ * object are stored as they are.
17
33
  * @async
18
34
  * @param {string} integrationId - ID of the integration to update.
19
35
  * @param {string} messageType - Type of message: 'errors', 'warnings', 'info', or 'logs'.
20
- * @param {string} messageTitle - Title of the message.
21
- * @param {string} messageBody - Body content of the message.
22
- * @param {string} messageTimestamp - Timestamp when the message was created.
36
+ * @param {string|Object} messageTitleOrItem - Title of the message, or the whole message item.
37
+ * @param {string} [messageBody] - Body content of the message (positional form).
38
+ * @param {string} [messageTimestamp] - Timestamp when the message was created (positional form).
23
39
  * @returns {Promise<Object>} The updated integration record.
24
40
  */
25
41
  async execute(
26
42
  integrationId,
27
43
  messageType,
28
- messageTitle,
44
+ messageTitleOrItem,
29
45
  messageBody,
30
46
  messageTimestamp
31
47
  ) {
@@ -33,12 +49,15 @@ class UpdateIntegrationMessages {
33
49
  await this.integrationRepository.updateIntegrationMessages(
34
50
  integrationId,
35
51
  messageType,
36
- messageTitle,
37
- messageBody,
38
- messageTimestamp
52
+ toMessageItem(
53
+ messageTitleOrItem,
54
+ messageBody,
55
+ messageTimestamp
56
+ ),
57
+ { keepLast: MAX_MESSAGES_PER_TYPE }
39
58
  );
40
59
  return integration;
41
60
  }
42
61
  }
43
62
 
44
- module.exports = { UpdateIntegrationMessages };
63
+ module.exports = { MAX_MESSAGES_PER_TYPE, UpdateIntegrationMessages };
package/modules/module.js CHANGED
@@ -8,6 +8,7 @@ const {
8
8
  const {
9
9
  createModuleRepository,
10
10
  } = require('./repositories/module-repository-factory');
11
+ const { readRateLimitPolicy } = require('./requester/rate-limit');
11
12
 
12
13
  // todo: this class should be a Domain class, and the Delegate function is preventing us from
13
14
  // doing that, we probably have to get rid of the Delegate class as well as the event based
@@ -51,6 +52,8 @@ class Module extends Delegate {
51
52
  this.delegateTypes.push(this.DLGT_CREDENTIAL_INVALIDATED);
52
53
  this.DLGT_CREDENTIAL_VALIDATED = 'CREDENTIAL_VALIDATED';
53
54
  this.delegateTypes.push(this.DLGT_CREDENTIAL_VALIDATED);
55
+ this.DLGT_RATE_LIMITED = 'RATE_LIMITED';
56
+ this.delegateTypes.push(this.DLGT_RATE_LIMITED);
54
57
 
55
58
  Object.assign(this, this.definition.requiredAuthMethods);
56
59
 
@@ -160,6 +163,8 @@ class Module extends Delegate {
160
163
  await this.deauthorize();
161
164
  } else if (delegateString === this.api.DLGT_INVALID_AUTH) {
162
165
  await this.markCredentialsInvalid(object);
166
+ } else if (delegateString === this.api.DLGT_RATE_LIMITED) {
167
+ await this.reportRateLimit(object);
163
168
  } else if (delegateString === this.api.DLGT_CREDENTIAL_RELOAD) {
164
169
  return this.reloadCredential();
165
170
  }
@@ -234,6 +239,28 @@ class Module extends Delegate {
234
239
  }
235
240
  }
236
241
 
242
+ async reportRateLimit(rateLimitError) {
243
+ const { reason, retryAt, policy, statusCode } = rateLimitError;
244
+ const links =
245
+ readRateLimitPolicy(this.apiClass)?.userHints?.[reason]?.links ??
246
+ [];
247
+ try {
248
+ await this.notify(this.DLGT_RATE_LIMITED, {
249
+ moduleName: this.name,
250
+ reason,
251
+ retryAt,
252
+ policy,
253
+ statusCode,
254
+ links,
255
+ });
256
+ } catch (err) {
257
+ this.logger.warn('Failed to propagate RATE_LIMITED', {
258
+ eventName: `${this.logger.name}.rate_limit_propagation_failed`,
259
+ error: err,
260
+ });
261
+ }
262
+ }
263
+
237
264
  async deauthorize() {
238
265
  //todo: Check if this is correct, we're instantiating a new api without params (credentials, tokens, etc...)
239
266
  this.api = new this.apiClass();
@@ -10,6 +10,7 @@ const { redactUrl } = require('../../logs/redact');
10
10
  const { toSanitizedSurrogate } = require('../../logs/serialize');
11
11
  const { getLoggerScope } = require('../../logs/context');
12
12
  const {
13
+ DEFAULT_MAX_IN_PROCESS_WAIT_MS,
13
14
  computeScopeKey,
14
15
  computeWaitMs,
15
16
  inProcessBudgetMs,
@@ -54,6 +55,8 @@ class Requester extends Delegate {
54
55
  this._authGeneration = 0;
55
56
  this.DLGT_INVALID_AUTH = 'INVALID_AUTH';
56
57
  this.delegateTypes.push(this.DLGT_INVALID_AUTH);
58
+ this.DLGT_RATE_LIMITED = 'RATE_LIMITED';
59
+ this.delegateTypes.push(this.DLGT_RATE_LIMITED);
57
60
  this.agent = get(params, 'agent', null);
58
61
 
59
62
  // Per-attempt HTTP timeout. Without this the framework called fetch()
@@ -528,6 +531,7 @@ class Requester extends Delegate {
528
531
  module: this._telemetryModuleLabel(),
529
532
  scopeKey: computeScopeKey(this._rateLimitPolicy, this),
530
533
  });
534
+ await this._notifyRateLimited(rateLimitError);
531
535
  throw this._maybeFlagTimeoutDuringBodyRead(rateLimitError, timeoutMs);
532
536
  }
533
537
 
@@ -595,6 +599,30 @@ class Requester extends Delegate {
595
599
  });
596
600
  }
597
601
 
602
+ async _notifyRateLimited(rateLimitError) {
603
+ if (!this._waitsLongerThanInProcessCap(rateLimitError)) return;
604
+ try {
605
+ await this.notify(this.DLGT_RATE_LIMITED, rateLimitError);
606
+ } catch (error) {
607
+ const logger = this.logger;
608
+ logger.warn('Rate limit notification failed', {
609
+ eventName: `${logger.name}.rate_limit_notify_failed`,
610
+ statusCode: rateLimitError.statusCode,
611
+ error,
612
+ });
613
+ }
614
+ }
615
+
616
+ _waitsLongerThanInProcessCap(rateLimitError) {
617
+ const waitMs = Number.isFinite(rateLimitError?.waitMs)
618
+ ? rateLimitError.waitMs
619
+ : new Date(rateLimitError?.retryAt).getTime() - Date.now();
620
+ const capMs =
621
+ this._rateLimitPolicy?.maxInProcessWaitMs ??
622
+ DEFAULT_MAX_IN_PROCESS_WAIT_MS;
623
+ return waitMs > capMs;
624
+ }
625
+
598
626
  _logRequestFailed(encodedUrl, options, status) {
599
627
  const logger = this.logger;
600
628
  if (!logger.isLevelEnabled('DEBUG')) return;
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.656.4fb07b4.0",
4
+ "version": "2.0.0--canary.658.164d92b.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -47,9 +47,9 @@
47
47
  }
48
48
  },
49
49
  "devDependencies": {
50
- "@friggframework/eslint-config": "2.0.0--canary.656.4fb07b4.0",
51
- "@friggframework/prettier-config": "2.0.0--canary.656.4fb07b4.0",
52
- "@friggframework/test": "2.0.0--canary.656.4fb07b4.0",
50
+ "@friggframework/eslint-config": "2.0.0--canary.658.164d92b.0",
51
+ "@friggframework/prettier-config": "2.0.0--canary.658.164d92b.0",
52
+ "@friggframework/test": "2.0.0--canary.658.164d92b.0",
53
53
  "@prisma/client": "^6.19.3",
54
54
  "@types/lodash": "4.17.15",
55
55
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -89,5 +89,5 @@
89
89
  "publishConfig": {
90
90
  "access": "public"
91
91
  },
92
- "gitHead": "4fb07b4e92324bbbf0d19153e2168642facc1182"
92
+ "gitHead": "164d92b13ea5ffc31d5f7861fbb936fc28ad3d97"
93
93
  }
@@ -1,6 +1,22 @@
1
1
  declare module "@friggframework/integrations" {
2
2
  import { Delegate, IFriggDelegate } from "@friggframework/core";
3
3
 
4
+ export type IntegrationMessageAction =
5
+ | { type: "RETRY_WHEN_READY" }
6
+ | { type: "LINK"; label: string; url: string };
7
+
8
+ export interface IntegrationMessage {
9
+ title: string;
10
+ message: string;
11
+ timestamp: number;
12
+ code?: string;
13
+ module?: string;
14
+ reason?: string;
15
+ retryAt?: string;
16
+ actions?: IntegrationMessageAction[];
17
+ [key: string]: unknown;
18
+ }
19
+
4
20
  export interface Integration {
5
21
  entities: any[];
6
22
  userId: string;
@@ -8,10 +24,10 @@ declare module "@friggframework/integrations" {
8
24
  config: any;
9
25
  version: string;
10
26
  messages: {
11
- errors: [];
12
- warnings: [];
13
- info: [];
14
- logs: [];
27
+ errors: IntegrationMessage[];
28
+ warnings: IntegrationMessage[];
29
+ info: IntegrationMessage[];
30
+ logs: IntegrationMessage[];
15
31
  };
16
32
  }
17
33
 
@@ -1,6 +1,6 @@
1
1
  declare module "@friggframework/module-plugin" {
2
2
  import { Delegate, IFriggDelegate } from "@friggframework/core";
3
- import type { RateLimitHint } from "@friggframework/errors";
3
+ import type { RateLimitError, RateLimitHint } from "@friggframework/errors";
4
4
 
5
5
  export interface Credential {
6
6
  id?: string;
@@ -66,6 +66,7 @@ declare module "@friggframework/module-plugin" {
66
66
  static requestTimeoutMs?: number;
67
67
 
68
68
  DLGT_INVALID_AUTH: string;
69
+ DLGT_RATE_LIMITED: string;
69
70
  requestTimeoutMs: number;
70
71
  backOff: number[];
71
72
  fetch: any;
@@ -85,6 +86,7 @@ declare module "@friggframework/module-plugin" {
85
86
  parseBody(response: any): Promise<any>;
86
87
  refreshAuth(): Promise<any>;
87
88
  _adoptNewerCredential(): Promise<boolean>;
89
+ _notifyRateLimited(rateLimitError: RateLimitError): Promise<void>;
88
90
 
89
91
  delegate: any;
90
92
  delegateTypes: any[];
@@ -102,6 +104,7 @@ declare module "@friggframework/module-plugin" {
102
104
  isRefreshable: boolean;
103
105
  refreshCount: number;
104
106
  DLGT_INVALID_AUTH: string;
107
+ DLGT_RATE_LIMITED: string;
105
108
  fetch: any;
106
109
 
107
110
  parseBody(response: any): Promise<any>;
@@ -117,6 +120,7 @@ declare module "@friggframework/module-plugin" {
117
120
  _delete(options: RequestOptions): Promise<any>;
118
121
  refreshAuth(): Promise<any>;
119
122
  _adoptNewerCredential(): Promise<boolean>;
123
+ _notifyRateLimited(rateLimitError: RateLimitError): Promise<void>;
120
124
  }
121
125
 
122
126
  type RequestOptions = {