@friggframework/core 2.0.0--canary.656.7302a64.0 → 2.0.0--canary.658.a5177aa.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
@@ -568,6 +568,53 @@ if (!userId) {
568
568
  }
569
569
  ```
570
570
 
571
+ **Rate-limit message** (ADR-049): when a wait is too long to sleep, the
572
+ Requester tells its delegate (`RATE_LIMITED`, with the `RateLimitError`) and
573
+ then throws. The notification takes the path of `CREDENTIAL_INVALIDATED`:
574
+ Requester, `Module`, `IntegrationBase`.
575
+
576
+ - The `Module` sends `{ moduleName, reason, retryAt, policy, statusCode, links }`.
577
+ `links` is `static rateLimit.userHints[reason].links`, or `[]`. The message,
578
+ url, body and headers of the error are never sent: the message reaches end
579
+ users.
580
+ - `IntegrationBase` writes one `WARN` (`integration.<name>.rate_limited`), and
581
+ `RecordRateLimitMessage` appends one item to the stored `warnings`. It
582
+ changes no status. A rate limit is not an `ERROR`, and an `ERROR` status
583
+ makes the queue worker drop every webhook.
584
+ - A report for the same module whose reset time is within 60 s of a stored one
585
+ is skipped. The check reads the stored `warnings` with
586
+ `findIntegrationMessages`, not `this.messages`, which is per instance and
587
+ empty on Postgres. It is a read then a write, so workers that report at the
588
+ same instant can still store a few copies.
589
+ - Every hop is best effort. A failure is logged, and the caller still gets the
590
+ `RateLimitError`.
591
+
592
+ ```javascript
593
+ {
594
+ title: 'Rate limit reached',
595
+ message: 'The hubspot API rate limit was reached and resets at 2026-09-28 14:31 UTC.',
596
+ timestamp: 1790000000000,
597
+ code: 'RATE_LIMITED',
598
+ module: 'hubspot',
599
+ reason: 'daily',
600
+ retryAt: '2026-09-28T14:30:15.000Z',
601
+ actions: [
602
+ { type: 'RETRY_WHEN_READY' },
603
+ { type: 'LINK', label: 'API usage limits', url: 'https://example.com/limits' },
604
+ ],
605
+ }
606
+ ```
607
+
608
+ The text is fixed. It holds the module name and the reset time in UTC, rounded
609
+ up to the minute, and nothing from the request.
610
+ `updateIntegrationMessages(id, type, title, body, timestamp)` still works, and
611
+ it also takes one item object, `updateIntegrationMessages(id, type, item)`,
612
+ whose keys are stored as they are. Every call appends.
613
+ `findIntegrationMessages(id, type)` reads the stored items of one type. The
614
+ `messages` of the record from `findIntegrationById` is not filled on Postgres
615
+ and Mongo, because the schema keeps the messages in the `errors`, `warnings`,
616
+ `info` and `logs` columns.
617
+
571
618
  ### 9. Logging System (`/logs`)
572
619
 
573
620
  **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(), () =>
@@ -865,7 +871,9 @@ class IntegrationBase {
865
871
  * something integration-level needs attention. Today this catches the
866
872
  * `CREDENTIAL_INVALIDATED` event Module fires from `markCredentialsInvalid`
867
873
  * and flips this integration's status to ERROR so the queue worker
868
- * stops processing further webhooks until the user re-authorizes.
874
+ * stops processing further webhooks until the user re-authorizes. It also
875
+ * catches `RATE_LIMITED`, which records one warning for the user and
876
+ * changes no status: a rate limit is not an error.
869
877
  *
870
878
  * Modules are wired to this delegate in `_appendModules()`, which runs
871
879
  * during `setIntegrationRecord()` — this covers every construction path
@@ -908,6 +916,17 @@ class IntegrationBase {
908
916
  });
909
917
  await this.persistStatus('ENABLED');
910
918
  }
919
+
920
+ if (delegateString === 'RATE_LIMITED') {
921
+ const moduleName = object?.moduleName ?? notifier?.name;
922
+ this.logger.warn('Module reported a rate limit', {
923
+ eventName: `${this.logger.name}.rate_limited`,
924
+ moduleName,
925
+ reason: object?.reason,
926
+ retryAt: object?.retryAt,
927
+ });
928
+ await this._recordRateLimitMessage({ ...object, moduleName });
929
+ }
911
930
  }
912
931
 
913
932
  /**
@@ -933,6 +952,23 @@ class IntegrationBase {
933
952
  });
934
953
  }
935
954
  }
955
+
956
+ /**
957
+ * Best-effort: the module's request is about to throw a RateLimitError,
958
+ * and a failed warning must not change that. A rate limit is not an ERROR,
959
+ * so this never touches the integration status.
960
+ * @param {Object} payload - The RATE_LIMITED payload of the module.
961
+ */
962
+ async _recordRateLimitMessage(payload) {
963
+ try {
964
+ await this.recordRateLimitMessage.execute(this.id, payload);
965
+ } catch (error) {
966
+ this.logger.error('Failed to record rate limit message', {
967
+ eventName: `${this.logger.name}.rate_limit_message_record_failed`,
968
+ error,
969
+ });
970
+ }
971
+ }
936
972
  }
937
973
 
938
974
  module.exports = { IntegrationBase };
@@ -14,6 +14,7 @@ const {
14
14
  IntegrationRepositoryInterface,
15
15
  } = require('./integration-repository-interface');
16
16
  const { validateConfigPatch } = require('./config-patch-shared');
17
+ const { toMessageItem } = require('./message-item-shared');
17
18
 
18
19
  class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
19
20
  constructor() {
@@ -94,7 +95,7 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
94
95
  async updateIntegrationMessages(
95
96
  integrationId,
96
97
  messageType,
97
- messageTitle,
98
+ messageTitleOrItem,
98
99
  messageBody,
99
100
  messageTimestamp
100
101
  ) {
@@ -108,11 +109,12 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
108
109
  }
109
110
  const messages = this._extractMessages(existing);
110
111
  const list = Array.isArray(messages[messageType]) ? [...messages[messageType]] : [];
111
- list.push({
112
- title: messageTitle ?? null,
113
- message: messageBody,
114
- timestamp: messageTimestamp,
115
- });
112
+ const item = toMessageItem(
113
+ messageTitleOrItem,
114
+ messageBody,
115
+ messageTimestamp
116
+ );
117
+ list.push({ ...item, title: item.title ?? null });
116
118
  const updatedMessages = { ...messages, [messageType]: list };
117
119
  await updateOne(
118
120
  this.prisma,
@@ -132,6 +134,23 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
132
134
  return true;
133
135
  }
134
136
 
137
+ async findIntegrationMessages(integrationId, messageType) {
138
+ const objectId = toObjectId(integrationId);
139
+ if (!objectId) {
140
+ throw new Error(`Integration ${integrationId} not found`);
141
+ }
142
+ const existing = await findOne(this.prisma, 'Integration', {
143
+ _id: objectId,
144
+ });
145
+ if (!existing) {
146
+ throw new Error(`Integration ${integrationId} not found`);
147
+ }
148
+ const messages = this._extractMessages(existing);
149
+ return Array.isArray(messages[messageType])
150
+ ? messages[messageType]
151
+ : [];
152
+ }
153
+
135
154
  async createIntegration(entities, userId, config) {
136
155
  const now = new Date();
137
156
  const document = {
@@ -97,26 +97,43 @@ class IntegrationRepositoryInterface {
97
97
  }
98
98
 
99
99
  /**
100
- * Update integration messages
100
+ * Append a message to the stored messages of one type. Takes the
101
+ * positional form (title, body, timestamp) or one message item object,
102
+ * whose keys are stored as they are.
101
103
  *
102
104
  * @param {string|number} integrationId - Integration ID
103
105
  * @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
106
+ * @param {string|Object} messageTitleOrItem - Message title, or the whole message item
107
+ * @param {string} [messageBody] - Message body (positional form)
108
+ * @param {Date} [messageTimestamp] - Message timestamp (positional form)
107
109
  * @returns {Promise<boolean>} Success indicator
108
110
  * @abstract
109
111
  */
110
112
  async updateIntegrationMessages(
111
113
  integrationId,
112
114
  messageType,
113
- messageTitle,
115
+ messageTitleOrItem,
114
116
  messageBody,
115
117
  messageTimestamp
116
118
  ) {
117
119
  throw new Error('Method updateIntegrationMessages must be implemented by subclass');
118
120
  }
119
121
 
122
+ /**
123
+ * Find the stored messages of one type. Reads the stored items, not the
124
+ * `messages` of an integration record, which some adapters do not fill.
125
+ *
126
+ * @param {string|number} integrationId - Integration ID
127
+ * @param {string} messageType - Type of message (errors, warnings, info, logs)
128
+ * @returns {Promise<Array>} The stored message items of that type, oldest first
129
+ * @abstract
130
+ */
131
+ async findIntegrationMessages(integrationId, messageType) {
132
+ throw new Error(
133
+ 'Method findIntegrationMessages must be implemented by subclass'
134
+ );
135
+ }
136
+
120
137
  /**
121
138
  * Create a new integration
122
139
  *
@@ -3,6 +3,7 @@ const {
3
3
  IntegrationRepositoryInterface,
4
4
  } = require('./integration-repository-interface');
5
5
  const { validateConfigPatch } = require('./config-patch-shared');
6
+ const { toMessageItem } = require('./message-item-shared');
6
7
 
7
8
  /**
8
9
  * MongoDB Integration Repository Adapter
@@ -229,15 +230,15 @@ 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 {string|Object} messageTitleOrItem - Message title, or the whole message item
234
+ * @param {string} [messageBody] - Message body (positional form)
235
+ * @param {Date} [messageTimestamp] - Message timestamp (positional form)
235
236
  * @returns {Promise<boolean>} Success indicator
236
237
  */
237
238
  async updateIntegrationMessages(
238
239
  integrationId,
239
240
  messageType,
240
- messageTitle,
241
+ messageTitleOrItem,
241
242
  messageBody,
242
243
  messageTimestamp
243
244
  ) {
@@ -251,17 +252,14 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
251
252
  }
252
253
 
253
254
  // Parse existing messages (JSON field)
254
- const messages = integration.messages || {};
255
- const messageArray = Array.isArray(messages[messageType])
256
- ? messages[messageType]
255
+ const messageArray = Array.isArray(integration[messageType])
256
+ ? integration[messageType]
257
257
  : [];
258
258
 
259
259
  // Add new message
260
- messageArray.push({
261
- title: messageTitle,
262
- message: messageBody,
263
- timestamp: messageTimestamp,
264
- });
260
+ messageArray.push(
261
+ toMessageItem(messageTitleOrItem, messageBody, messageTimestamp)
262
+ );
265
263
 
266
264
  // Update messages
267
265
  await this.prisma.integration.update({
@@ -274,6 +272,28 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
274
272
  return true; // Mongoose compatibility
275
273
  }
276
274
 
275
+ /**
276
+ * Find the stored messages of one type
277
+ *
278
+ * @param {string} integrationId - Integration ID
279
+ * @param {string} messageType - Type of message (errors, warnings, info, logs)
280
+ * @returns {Promise<Array>} The stored message items of that type, oldest first
281
+ */
282
+ async findIntegrationMessages(integrationId, messageType) {
283
+ const integration = await this.prisma.integration.findUnique({
284
+ where: { id: integrationId },
285
+ select: { [messageType]: true },
286
+ });
287
+
288
+ if (!integration) {
289
+ throw new Error(`Integration ${integrationId} not found`);
290
+ }
291
+
292
+ return Array.isArray(integration[messageType])
293
+ ? integration[messageType]
294
+ : [];
295
+ }
296
+
277
297
  /**
278
298
  * Find all integrations whose entity set includes the given entity ID.
279
299
  *
@@ -3,6 +3,7 @@ const {
3
3
  IntegrationRepositoryInterface,
4
4
  } = require('./integration-repository-interface');
5
5
  const { validateConfigPatch } = require('./config-patch-shared');
6
+ const { toMessageItem } = require('./message-item-shared');
6
7
  const { strictIntId } = require('./report-id');
7
8
 
8
9
  /**
@@ -273,15 +274,15 @@ 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 {string|Object} messageTitleOrItem - Message title, or the whole message item
278
+ * @param {string} [messageBody] - Message body (positional form)
279
+ * @param {Date} [messageTimestamp] - Message timestamp (positional form)
279
280
  * @returns {Promise<boolean>} Success indicator
280
281
  */
281
282
  async updateIntegrationMessages(
282
283
  integrationId,
283
284
  messageType,
284
- messageTitle,
285
+ messageTitleOrItem,
285
286
  messageBody,
286
287
  messageTimestamp
287
288
  ) {
@@ -297,17 +298,14 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
297
298
  }
298
299
 
299
300
  // Parse existing messages (JSON field)
300
- const messages = integration.messages || {};
301
- const messageArray = Array.isArray(messages[messageType])
302
- ? messages[messageType]
301
+ const messageArray = Array.isArray(integration[messageType])
302
+ ? integration[messageType]
303
303
  : [];
304
304
 
305
305
  // Add new message
306
- messageArray.push({
307
- title: messageTitle,
308
- message: messageBody,
309
- timestamp: messageTimestamp,
310
- });
306
+ messageArray.push(
307
+ toMessageItem(messageTitleOrItem, messageBody, messageTimestamp)
308
+ );
311
309
 
312
310
  // Update messages
313
311
  await this.prisma.integration.update({
@@ -320,6 +318,29 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
320
318
  return true; // Mongoose compatibility
321
319
  }
322
320
 
321
+ /**
322
+ * Find the stored messages of one type
323
+ *
324
+ * @param {string} integrationId - Integration ID (string from application layer)
325
+ * @param {string} messageType - Type of message (errors, warnings, info, logs)
326
+ * @returns {Promise<Array>} The stored message items of that type, oldest first
327
+ */
328
+ async findIntegrationMessages(integrationId, messageType) {
329
+ const intId = this._convertId(integrationId);
330
+ const integration = await this.prisma.integration.findUnique({
331
+ where: { id: intId },
332
+ select: { [messageType]: true },
333
+ });
334
+
335
+ if (!integration) {
336
+ throw new Error(`Integration ${integrationId} not found`);
337
+ }
338
+
339
+ return Array.isArray(integration[messageType])
340
+ ? integration[messageType]
341
+ : [];
342
+ }
343
+
323
344
  /**
324
345
  * Create a new integration
325
346
  *
@@ -0,0 +1,24 @@
1
+ const isItem = (value) =>
2
+ value !== null && typeof value === 'object' && !Array.isArray(value);
3
+
4
+ /**
5
+ * The stored shape of a message, for IntegrationRepository
6
+ * .updateIntegrationMessages(). The method takes the positional form (title,
7
+ * body, timestamp) or one item object. The keys of an item object are stored
8
+ * as they are, so a caller can attach what a client acts on, like a code or
9
+ * a list of actions.
10
+ * @param {string|Object} titleOrItem - The title, or the whole item.
11
+ * @param {string} [messageBody] - Ignored when an item is given.
12
+ * @param {number|Date} [messageTimestamp] - Ignored when an item is given.
13
+ * @returns {Object} A new object; the given item is never returned itself.
14
+ */
15
+ function toMessageItem(titleOrItem, messageBody, messageTimestamp) {
16
+ if (isItem(titleOrItem)) return { ...titleOrItem };
17
+ return {
18
+ title: titleOrItem,
19
+ message: messageBody,
20
+ timestamp: messageTimestamp,
21
+ };
22
+ }
23
+
24
+ module.exports = { toMessageItem };
@@ -1,5 +1,6 @@
1
1
  const { v4: uuid } = require('uuid');
2
2
  const { validateConfigPatch } = require('../../repositories/config-patch-shared');
3
+ const { toMessageItem } = require('../../repositories/message-item-shared');
3
4
 
4
5
  class TestIntegrationRepository {
5
6
  constructor() {
@@ -72,18 +73,30 @@ class TestIntegrationRepository {
72
73
  return results;
73
74
  }
74
75
 
75
- async updateIntegrationMessages(id, type, title, body, timestamp) {
76
+ async updateIntegrationMessages(id, type, titleOrItem, body, timestamp) {
76
77
  const rec = this.store.get(id);
77
78
  if (!rec) {
78
79
  this.operationHistory.push({ operation: 'updateMessages', id, success: false });
79
80
  return false;
80
81
  }
81
82
  if (!rec.messages[type]) rec.messages[type] = [];
82
- rec.messages[type].push({ title, message: body, timestamp });
83
+ rec.messages[type].push(toMessageItem(titleOrItem, body, timestamp));
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,85 @@
1
+ const RATE_LIMITED = 'RATE_LIMITED';
2
+ const SAME_LIMIT_WINDOW_MS = 60_000;
3
+ const MINUTE_MS = 60_000;
4
+
5
+ const isLink = (link) =>
6
+ typeof link?.label === 'string' && typeof link?.url === 'string';
7
+
8
+ const linkActions = (links) =>
9
+ (Array.isArray(links) ? links : [])
10
+ .filter(isLink)
11
+ .map(({ label, url }) => ({ type: 'LINK', label, url }));
12
+
13
+ function utcMinuteAfter(date) {
14
+ const minute = new Date(Math.ceil(date.getTime() / MINUTE_MS) * MINUTE_MS);
15
+ return `${minute.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
16
+ }
17
+
18
+ /**
19
+ * Use case that records one user-facing warning when a module reports a rate
20
+ * limit that is too long to wait for. The text is fixed and the payload's
21
+ * message, url, body and headers are never read, because the message reaches
22
+ * end users. It changes no integration status: a rate limit is not an error.
23
+ * @class RecordRateLimitMessage
24
+ */
25
+ class RecordRateLimitMessage {
26
+ /**
27
+ * @param {Object} params - Configuration parameters.
28
+ * @param {import('../repositories/integration-repository-interface').IntegrationRepositoryInterface} params.integrationRepository - Repository for integration data operations.
29
+ */
30
+ constructor({ integrationRepository }) {
31
+ this.integrationRepository = integrationRepository;
32
+ }
33
+
34
+ /**
35
+ * Writes the warning, unless the stored warnings already hold one for the
36
+ * same module whose reset time is within a minute of this one. Many workers
37
+ * hit one limit together and each reports it, so the check reads the
38
+ * stored warnings and not the messages of this instance.
39
+ * @async
40
+ * @param {string} integrationId - ID of the integration to warn on.
41
+ * @param {Object} payload - The RATE_LIMITED payload of the Module.
42
+ * @param {string} payload.moduleName
43
+ * @param {string} payload.reason
44
+ * @param {Date|string|number} payload.retryAt
45
+ * @param {Array<{label: string, url: string}>} [payload.links]
46
+ * @returns {Promise<boolean>} True when it wrote a warning.
47
+ * @throws When the read or the write fails; the caller decides what a
48
+ * failed warning is worth.
49
+ */
50
+ async execute(integrationId, { moduleName, reason, retryAt, links }) {
51
+ const resetAt = new Date(retryAt);
52
+ const warnings =
53
+ await this.integrationRepository.findIntegrationMessages(
54
+ integrationId,
55
+ 'warnings'
56
+ );
57
+ const alreadyRecorded = warnings.some(
58
+ (warning) =>
59
+ warning?.code === RATE_LIMITED &&
60
+ warning.module === moduleName &&
61
+ Math.abs(Date.parse(warning.retryAt) - resetAt.getTime()) <=
62
+ SAME_LIMIT_WINDOW_MS
63
+ );
64
+ if (alreadyRecorded) return false;
65
+
66
+ const resetsAt = utcMinuteAfter(resetAt);
67
+ await this.integrationRepository.updateIntegrationMessages(
68
+ integrationId,
69
+ 'warnings',
70
+ {
71
+ title: 'Rate limit reached',
72
+ message: `The ${moduleName} API rate limit was reached and resets at ${resetsAt}.`,
73
+ timestamp: Date.now(),
74
+ code: RATE_LIMITED,
75
+ module: moduleName,
76
+ reason,
77
+ retryAt: resetAt.toISOString(),
78
+ actions: [{ type: 'RETRY_WHEN_READY' }, ...linkActions(links)],
79
+ }
80
+ );
81
+ return true;
82
+ }
83
+ }
84
+
85
+ module.exports = { RecordRateLimitMessage };
@@ -13,19 +13,21 @@ class UpdateIntegrationMessages {
13
13
  }
14
14
 
15
15
  /**
16
- * Executes the integration messages update.
16
+ * Executes the integration messages update. Pass the positional form
17
+ * (title, body, timestamp) or one message item object; the keys of an item
18
+ * object are stored as they are.
17
19
  * @async
18
20
  * @param {string} integrationId - ID of the integration to update.
19
21
  * @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.
22
+ * @param {string|Object} messageTitleOrItem - Title of the message, or the whole message item.
23
+ * @param {string} [messageBody] - Body content of the message (positional form).
24
+ * @param {string} [messageTimestamp] - Timestamp when the message was created (positional form).
23
25
  * @returns {Promise<Object>} The updated integration record.
24
26
  */
25
27
  async execute(
26
28
  integrationId,
27
29
  messageType,
28
- messageTitle,
30
+ messageTitleOrItem,
29
31
  messageBody,
30
32
  messageTimestamp
31
33
  ) {
@@ -33,7 +35,7 @@ class UpdateIntegrationMessages {
33
35
  await this.integrationRepository.updateIntegrationMessages(
34
36
  integrationId,
35
37
  messageType,
36
- messageTitle,
38
+ messageTitleOrItem,
37
39
  messageBody,
38
40
  messageTimestamp
39
41
  );
package/modules/module.js CHANGED
@@ -51,6 +51,8 @@ class Module extends Delegate {
51
51
  this.delegateTypes.push(this.DLGT_CREDENTIAL_INVALIDATED);
52
52
  this.DLGT_CREDENTIAL_VALIDATED = 'CREDENTIAL_VALIDATED';
53
53
  this.delegateTypes.push(this.DLGT_CREDENTIAL_VALIDATED);
54
+ this.DLGT_RATE_LIMITED = 'RATE_LIMITED';
55
+ this.delegateTypes.push(this.DLGT_RATE_LIMITED);
54
56
 
55
57
  Object.assign(this, this.definition.requiredAuthMethods);
56
58
 
@@ -160,6 +162,8 @@ class Module extends Delegate {
160
162
  await this.deauthorize();
161
163
  } else if (delegateString === this.api.DLGT_INVALID_AUTH) {
162
164
  await this.markCredentialsInvalid(object);
165
+ } else if (delegateString === this.api.DLGT_RATE_LIMITED) {
166
+ await this.reportRateLimit(object);
163
167
  } else if (delegateString === this.api.DLGT_CREDENTIAL_RELOAD) {
164
168
  return this.reloadCredential();
165
169
  }
@@ -234,6 +238,32 @@ class Module extends Delegate {
234
238
  }
235
239
  }
236
240
 
241
+ /**
242
+ * Passes a rate limit the api reported to the parent delegate, best
243
+ * effort. The payload never holds the error's message, url, body or
244
+ * headers, because the parent shows it to end users.
245
+ * @param {import('../errors').RateLimitError} rateLimitError
246
+ */
247
+ async reportRateLimit(rateLimitError) {
248
+ const { reason, retryAt, policy, statusCode } = rateLimitError;
249
+ const links = this.apiClass.rateLimit?.userHints?.[reason]?.links ?? [];
250
+ try {
251
+ await this.notify(this.DLGT_RATE_LIMITED, {
252
+ moduleName: this.name,
253
+ reason,
254
+ retryAt,
255
+ policy,
256
+ statusCode,
257
+ links,
258
+ });
259
+ } catch (err) {
260
+ this.logger.error('Failed to propagate RATE_LIMITED', {
261
+ eventName: `${this.logger.name}.rate_limited_propagation_failed`,
262
+ error: err,
263
+ });
264
+ }
265
+ }
266
+
237
267
  async deauthorize() {
238
268
  //todo: Check if this is correct, we're instantiating a new api without params (credentials, tokens, etc...)
239
269
  this.api = new this.apiClass();
@@ -67,6 +67,8 @@ class Requester extends Delegate {
67
67
  this._authGeneration = 0;
68
68
  this.DLGT_INVALID_AUTH = 'INVALID_AUTH';
69
69
  this.delegateTypes.push(this.DLGT_INVALID_AUTH);
70
+ this.DLGT_RATE_LIMITED = 'RATE_LIMITED';
71
+ this.delegateTypes.push(this.DLGT_RATE_LIMITED);
70
72
  this.agent = get(params, 'agent', null);
71
73
 
72
74
  // Per-attempt HTTP timeout. Without this the framework called fetch()
@@ -557,6 +559,7 @@ class Requester extends Delegate {
557
559
  module: this._telemetryModuleLabel(),
558
560
  scopeKey: computeScopeKey(this._rateLimitPolicy, this),
559
561
  });
562
+ await this._notifyRateLimited(rateLimitError);
560
563
  throw this._maybeFlagTimeoutDuringBodyRead(rateLimitError, timeoutMs);
561
564
  }
562
565
 
@@ -636,6 +639,24 @@ class Requester extends Delegate {
636
639
  });
637
640
  }
638
641
 
642
+ /**
643
+ * Tells the delegate a wait was too long to sleep. Best effort: a failed
644
+ * notification is logged and never replaces the RateLimitError the caller
645
+ * is about to receive.
646
+ */
647
+ async _notifyRateLimited(rateLimitError) {
648
+ try {
649
+ await this.notify(this.DLGT_RATE_LIMITED, rateLimitError);
650
+ } catch (error) {
651
+ const logger = this.logger;
652
+ logger.warn('Rate limit notification failed', {
653
+ eventName: `${logger.name}.rate_limit_notify_failed`,
654
+ statusCode: rateLimitError.statusCode,
655
+ error,
656
+ });
657
+ }
658
+ }
659
+
639
660
  _logRequestFailed(encodedUrl, options, status) {
640
661
  const logger = this.logger;
641
662
  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.7302a64.0",
4
+ "version": "2.0.0--canary.658.a5177aa.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.7302a64.0",
51
- "@friggframework/prettier-config": "2.0.0--canary.656.7302a64.0",
52
- "@friggframework/test": "2.0.0--canary.656.7302a64.0",
50
+ "@friggframework/eslint-config": "2.0.0--canary.658.a5177aa.0",
51
+ "@friggframework/prettier-config": "2.0.0--canary.658.a5177aa.0",
52
+ "@friggframework/test": "2.0.0--canary.658.a5177aa.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": "7302a64c61c7eb20e0b42a972c0406f75b1f347b"
92
+ "gitHead": "a5177aa26a8c9bd960261cfe19c3c99f08ccc701"
93
93
  }
@@ -1,6 +1,27 @@
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
+ /**
9
+ * A stored message. The framework stores the keys of an item as given, so a
10
+ * caller can add its own. A rate limit adds `code: "RATE_LIMITED"`,
11
+ * `module`, `reason`, `retryAt` (ISO 8601) and `actions`.
12
+ */
13
+ export interface IntegrationMessage {
14
+ title: string;
15
+ message: string;
16
+ timestamp: number;
17
+ code?: string;
18
+ module?: string;
19
+ reason?: string;
20
+ retryAt?: string;
21
+ actions?: IntegrationMessageAction[];
22
+ [key: string]: unknown;
23
+ }
24
+
4
25
  export interface Integration {
5
26
  entities: any[];
6
27
  userId: string;
@@ -8,10 +29,10 @@ declare module "@friggframework/integrations" {
8
29
  config: any;
9
30
  version: string;
10
31
  messages: {
11
- errors: [];
12
- warnings: [];
13
- info: [];
14
- logs: [];
32
+ errors: IntegrationMessage[];
33
+ warnings: IntegrationMessage[];
34
+ info: IntegrationMessage[];
35
+ logs: IntegrationMessage[];
15
36
  };
16
37
  }
17
38
 
@@ -75,6 +75,8 @@ declare module "@friggframework/module-plugin" {
75
75
  static requestTimeoutMs?: number;
76
76
 
77
77
  DLGT_INVALID_AUTH: string;
78
+ /** Notified with the `RateLimitError` right before a wait that is too long throws. */
79
+ DLGT_RATE_LIMITED: string;
78
80
  requestTimeoutMs: number;
79
81
  backOff: number[];
80
82
  fetch: any;
@@ -111,6 +113,7 @@ declare module "@friggframework/module-plugin" {
111
113
  isRefreshable: boolean;
112
114
  refreshCount: number;
113
115
  DLGT_INVALID_AUTH: string;
116
+ DLGT_RATE_LIMITED: string;
114
117
  fetch: any;
115
118
 
116
119
  parseBody(response: any): Promise<any>;