@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 +47 -0
- package/integrations/integration-base.js +37 -1
- package/integrations/repositories/integration-repository-documentdb.js +25 -6
- package/integrations/repositories/integration-repository-interface.js +22 -5
- package/integrations/repositories/integration-repository-mongo.js +32 -12
- package/integrations/repositories/integration-repository-postgres.js +33 -12
- package/integrations/repositories/message-item-shared.js +24 -0
- package/integrations/tests/doubles/test-integration-repository.js +15 -2
- package/integrations/use-cases/record-rate-limit-message.js +85 -0
- package/integrations/use-cases/update-integration-messages.js +8 -6
- package/modules/module.js +30 -0
- package/modules/requester/requester.js +21 -0
- package/package.json +5 -5
- package/types/integrations/index.d.ts +25 -4
- package/types/module-plugin/index.d.ts +3 -0
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
*
|
|
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}
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
|
255
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
|
301
|
-
|
|
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
|
-
|
|
308
|
-
|
|
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,
|
|
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(
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
51
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
52
|
-
"@friggframework/test": "2.0.0--canary.
|
|
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": "
|
|
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>;
|