@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 +44 -0
- package/integrations/integration-base.js +16 -0
- package/integrations/repositories/integration-repository-documentdb.js +23 -10
- package/integrations/repositories/integration-repository-interface.js +21 -7
- package/integrations/repositories/integration-repository-mongo.js +29 -16
- package/integrations/repositories/integration-repository-postgres.js +30 -16
- package/integrations/repositories/message-item-shared.js +4 -0
- package/integrations/tests/doubles/prisma-message-row.js +24 -0
- package/integrations/tests/doubles/test-integration-repository.js +15 -2
- package/integrations/use-cases/record-rate-limit-message.js +110 -0
- package/integrations/use-cases/update-integration-messages.js +28 -9
- package/modules/module.js +27 -0
- package/modules/requester/requester.js +28 -0
- package/package.json +5 -5
- package/types/integrations/index.d.ts +20 -4
- package/types/module-plugin/index.d.ts +5 -1
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
|
-
|
|
98
|
-
|
|
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 =
|
|
111
|
-
list.push({
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
*
|
|
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 {
|
|
105
|
-
* @param {
|
|
106
|
-
* @param {
|
|
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
|
-
|
|
114
|
-
|
|
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 {
|
|
233
|
-
* @param {
|
|
234
|
-
* @param {
|
|
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
|
-
|
|
241
|
-
|
|
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
|
|
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 {
|
|
277
|
-
* @param {
|
|
278
|
-
* @param {
|
|
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
|
-
|
|
285
|
-
|
|
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
|
|
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,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,
|
|
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(
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
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.
|
|
51
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
52
|
-
"@friggframework/test": "2.0.0--canary.
|
|
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": "
|
|
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 = {
|