@bbradar/mcp 0.1.19 → 0.1.20
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/README.md +37 -0
- package/dist/bbradarClient.d.ts +108 -2
- package/dist/bbradarClient.js +490 -45
- package/dist/bbradarClient.js.map +1 -1
- package/dist/cache.d.ts +5 -1
- package/dist/cache.js +22 -4
- package/dist/cache.js.map +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.js +7 -0
- package/dist/config.js.map +1 -1
- package/dist/server.js +533 -113
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
package/dist/bbradarClient.js
CHANGED
|
@@ -2,12 +2,19 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { TtlCache } from "./cache.js";
|
|
3
3
|
import { stableStringify } from "./json.js";
|
|
4
4
|
import { encodeProgramIdPath } from "./url.js";
|
|
5
|
+
const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60_000;
|
|
6
|
+
const TARGET_CHANGES_CONCURRENCY_LIMIT = 3;
|
|
7
|
+
const UPSTREAM_KEY_IP_REQUEST_LIMIT = 60;
|
|
8
|
+
const UPSTREAM_KEY_IP_WINDOW_MS = 60_000;
|
|
5
9
|
export class BBRadarApiError extends Error {
|
|
6
10
|
requestId;
|
|
7
11
|
upstreamRequestId;
|
|
8
12
|
status;
|
|
9
13
|
detail;
|
|
10
14
|
errors;
|
|
15
|
+
cached;
|
|
16
|
+
coalesced;
|
|
17
|
+
cacheExpiresAt;
|
|
11
18
|
constructor(params) {
|
|
12
19
|
super(params.message, { cause: params.cause });
|
|
13
20
|
this.name = "BBRadarApiError";
|
|
@@ -16,23 +23,75 @@ export class BBRadarApiError extends Error {
|
|
|
16
23
|
this.status = params.status;
|
|
17
24
|
this.detail = params.detail;
|
|
18
25
|
this.errors = params.errors;
|
|
26
|
+
this.cached = params.cached ?? false;
|
|
27
|
+
this.coalesced = params.coalesced ?? false;
|
|
28
|
+
this.cacheExpiresAt = params.cacheExpiresAt;
|
|
19
29
|
}
|
|
20
30
|
}
|
|
21
31
|
export class BBRadarClient {
|
|
22
32
|
config;
|
|
23
33
|
cache;
|
|
34
|
+
negativeCache;
|
|
24
35
|
inFlight = new Map();
|
|
36
|
+
endpointMetrics = new Map();
|
|
37
|
+
requestQueue = [];
|
|
38
|
+
endpointRequestQueues = new Map();
|
|
39
|
+
endpointActiveRequests = new Map();
|
|
40
|
+
upstreamRateLimits = new Map();
|
|
41
|
+
upstreamRequestTimestampsMs = [];
|
|
42
|
+
activeRequests = 0;
|
|
43
|
+
globalBlockedUntilMs;
|
|
25
44
|
constructor(config) {
|
|
26
45
|
this.config = config;
|
|
27
46
|
this.cache = new TtlCache(config.cacheMaxEntries, config.cacheTtlMs);
|
|
47
|
+
this.negativeCache = new TtlCache(config.cacheMaxEntries, config.negativeCacheTtlMs);
|
|
28
48
|
}
|
|
29
49
|
diagnostics() {
|
|
50
|
+
const endpointEntries = [...this.endpointMetrics.entries()];
|
|
51
|
+
const totalRequests = endpointEntries.reduce((total, [, metric]) => total + metric.requests, 0);
|
|
52
|
+
const totalErrors = endpointEntries.reduce((total, [, metric]) => total + metric.errors, 0);
|
|
53
|
+
const totalMs = endpointEntries.reduce((total, [, metric]) => total + metric.totalMs, 0);
|
|
30
54
|
return {
|
|
31
55
|
response_cache: {
|
|
32
56
|
enabled: this.config.cacheTtlMs > 0 && this.config.cacheMaxEntries > 0,
|
|
33
57
|
...this.cache.stats()
|
|
34
58
|
},
|
|
35
|
-
|
|
59
|
+
negative_response_cache: {
|
|
60
|
+
enabled: this.config.negativeCacheTtlMs > 0 && this.config.cacheMaxEntries > 0,
|
|
61
|
+
...this.negativeCache.stats()
|
|
62
|
+
},
|
|
63
|
+
in_flight_requests: this.inFlight.size,
|
|
64
|
+
request_concurrency: {
|
|
65
|
+
limit: this.config.maxConcurrentRequests,
|
|
66
|
+
active: this.activeRequests,
|
|
67
|
+
queued: this.requestQueue.length,
|
|
68
|
+
endpoint_limits: {
|
|
69
|
+
target_changes: {
|
|
70
|
+
limit: TARGET_CHANGES_CONCURRENCY_LIMIT,
|
|
71
|
+
active: this.endpointActiveRequests.get("target_changes") ?? 0,
|
|
72
|
+
queued: this.endpointRequestQueues.get("target_changes")?.length ?? 0
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
upstream_request_window: this.upstreamRequestWindowDiagnostics(),
|
|
77
|
+
upstream_rate_limit: this.upstreamRateLimitDiagnostics(),
|
|
78
|
+
upstream_requests: {
|
|
79
|
+
actual_requests: totalRequests,
|
|
80
|
+
errors: totalErrors,
|
|
81
|
+
avg_ms: totalRequests > 0 ? Math.round(totalMs / totalRequests) : 0,
|
|
82
|
+
max_ms: endpointEntries.reduce((maximum, [, metric]) => Math.max(maximum, metric.maxMs), 0),
|
|
83
|
+
by_endpoint: Object.fromEntries(endpointEntries.map(([endpoint, metric]) => [
|
|
84
|
+
endpoint,
|
|
85
|
+
{
|
|
86
|
+
requests: metric.requests,
|
|
87
|
+
errors: metric.errors,
|
|
88
|
+
avg_ms: metric.requests > 0 ? Math.round(metric.totalMs / metric.requests) : 0,
|
|
89
|
+
max_ms: metric.maxMs,
|
|
90
|
+
last_ms: metric.lastMs,
|
|
91
|
+
statuses: Object.fromEntries(metric.statuses)
|
|
92
|
+
}
|
|
93
|
+
]))
|
|
94
|
+
}
|
|
36
95
|
};
|
|
37
96
|
}
|
|
38
97
|
async validateKey() {
|
|
@@ -116,6 +175,10 @@ export class BBRadarClient {
|
|
|
116
175
|
body: options.body ?? null
|
|
117
176
|
});
|
|
118
177
|
const cacheable = method === "GET" && (options.cache ?? true) && this.config.cacheTtlMs > 0 && this.config.cacheMaxEntries > 0;
|
|
178
|
+
const negativeCacheable = method === "GET" &&
|
|
179
|
+
(options.cache ?? true) &&
|
|
180
|
+
this.config.negativeCacheTtlMs > 0 &&
|
|
181
|
+
this.config.cacheMaxEntries > 0;
|
|
119
182
|
const coalesceable = method === "GET" && (options.coalesce ?? true);
|
|
120
183
|
if (cacheable) {
|
|
121
184
|
const cached = this.cache.get(requestKey);
|
|
@@ -128,6 +191,17 @@ export class BBRadarClient {
|
|
|
128
191
|
};
|
|
129
192
|
}
|
|
130
193
|
}
|
|
194
|
+
if (negativeCacheable) {
|
|
195
|
+
const cachedError = this.negativeCache.get(requestKey);
|
|
196
|
+
if (cachedError) {
|
|
197
|
+
throw new BBRadarApiError({
|
|
198
|
+
...cachedError.value,
|
|
199
|
+
requestId,
|
|
200
|
+
cached: true,
|
|
201
|
+
cacheExpiresAt: new Date(cachedError.expiresAt).toISOString()
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
131
205
|
if (coalesceable) {
|
|
132
206
|
const pending = this.inFlight.get(requestKey);
|
|
133
207
|
if (pending) {
|
|
@@ -141,7 +215,7 @@ export class BBRadarClient {
|
|
|
141
215
|
};
|
|
142
216
|
}
|
|
143
217
|
catch (error) {
|
|
144
|
-
throw cloneApiError(error, requestId);
|
|
218
|
+
throw cloneApiError(error, requestId, true);
|
|
145
219
|
}
|
|
146
220
|
}
|
|
147
221
|
}
|
|
@@ -154,12 +228,30 @@ export class BBRadarClient {
|
|
|
154
228
|
if (cacheable) {
|
|
155
229
|
this.cache.set(requestKey, result);
|
|
156
230
|
}
|
|
231
|
+
if (negativeCacheable) {
|
|
232
|
+
this.negativeCache.delete(requestKey);
|
|
233
|
+
}
|
|
157
234
|
return {
|
|
158
235
|
...result,
|
|
159
236
|
requestId,
|
|
160
237
|
cached: false
|
|
161
238
|
};
|
|
162
239
|
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (negativeCacheable &&
|
|
242
|
+
error instanceof BBRadarApiError &&
|
|
243
|
+
error.status !== undefined &&
|
|
244
|
+
isNegativeCacheStatus(error.status)) {
|
|
245
|
+
this.negativeCache.set(requestKey, {
|
|
246
|
+
message: error.message,
|
|
247
|
+
upstreamRequestId: error.upstreamRequestId,
|
|
248
|
+
status: error.status,
|
|
249
|
+
detail: error.detail,
|
|
250
|
+
errors: error.errors
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
163
255
|
finally {
|
|
164
256
|
if (coalesceable) {
|
|
165
257
|
this.inFlight.delete(requestKey);
|
|
@@ -167,57 +259,304 @@ export class BBRadarClient {
|
|
|
167
259
|
}
|
|
168
260
|
}
|
|
169
261
|
async fetchFromApi(options, method, url, requestId) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
262
|
+
return this.withRequestSlot(options.path, async () => {
|
|
263
|
+
const rateLimitBucket = rateLimitEndpointGroup(options.path);
|
|
264
|
+
this.reserveUpstreamRequest(requestId, rateLimitBucket);
|
|
265
|
+
const startedAt = Date.now();
|
|
266
|
+
const controller = new AbortController();
|
|
267
|
+
const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
|
|
268
|
+
let status;
|
|
269
|
+
let failed = true;
|
|
270
|
+
try {
|
|
271
|
+
const init = {
|
|
272
|
+
method,
|
|
273
|
+
headers: this.headers(options.body),
|
|
274
|
+
signal: controller.signal
|
|
275
|
+
};
|
|
276
|
+
if (options.body !== undefined) {
|
|
277
|
+
init.body = JSON.stringify(options.body);
|
|
278
|
+
}
|
|
279
|
+
const response = await fetch(url, init);
|
|
280
|
+
status = response.status;
|
|
281
|
+
this.observeUpstreamRateLimit(rateLimitBucket, response.headers, response.status);
|
|
282
|
+
const upstreamRequestId = extractRequestId(response.headers);
|
|
283
|
+
const data = await parseResponseBody(response);
|
|
284
|
+
if (!response.ok) {
|
|
285
|
+
const params = {
|
|
286
|
+
message: formatApiErrorMessage(response.status, data),
|
|
287
|
+
requestId,
|
|
288
|
+
status: response.status,
|
|
289
|
+
detail: extractObjectField(data, "detail"),
|
|
290
|
+
errors: extractObjectField(data, "errors")
|
|
291
|
+
};
|
|
292
|
+
if (upstreamRequestId) {
|
|
293
|
+
params.upstreamRequestId = upstreamRequestId;
|
|
294
|
+
}
|
|
295
|
+
throw new BBRadarApiError(params);
|
|
296
|
+
}
|
|
297
|
+
const result = {
|
|
298
|
+
data: data,
|
|
189
299
|
status: response.status,
|
|
190
|
-
|
|
191
|
-
errors: extractObjectField(data, "errors")
|
|
300
|
+
fetchedAt: new Date().toISOString()
|
|
192
301
|
};
|
|
193
302
|
if (upstreamRequestId) {
|
|
194
|
-
|
|
303
|
+
result.upstreamRequestId = upstreamRequestId;
|
|
195
304
|
}
|
|
196
|
-
|
|
305
|
+
failed = false;
|
|
306
|
+
return result;
|
|
197
307
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
308
|
+
catch (error) {
|
|
309
|
+
if (error instanceof BBRadarApiError) {
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
throw new BBRadarApiError({
|
|
313
|
+
message: error instanceof Error && error.name === "AbortError" ? "BBRadar API request timed out." : "BBRadar API request failed.",
|
|
314
|
+
requestId,
|
|
315
|
+
cause: error
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
clearTimeout(timeout);
|
|
320
|
+
this.recordEndpointMetric(options.path, Date.now() - startedAt, status, failed);
|
|
205
321
|
}
|
|
206
|
-
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
async withRequestSlot(path, callback) {
|
|
325
|
+
const endpointGroup = rateLimitEndpointGroup(path);
|
|
326
|
+
const endpointLimit = endpointConcurrencyLimit(endpointGroup);
|
|
327
|
+
if (endpointLimit === undefined) {
|
|
328
|
+
return this.withGlobalRequestSlot(callback);
|
|
207
329
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
330
|
+
return this.withEndpointRequestSlot(endpointGroup, endpointLimit, () => this.withGlobalRequestSlot(callback));
|
|
331
|
+
}
|
|
332
|
+
async withGlobalRequestSlot(callback) {
|
|
333
|
+
const limit = Math.max(1, this.config.maxConcurrentRequests);
|
|
334
|
+
if (this.activeRequests >= limit) {
|
|
335
|
+
await new Promise((resolve) => this.requestQueue.push(resolve));
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
this.activeRequests += 1;
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
return await callback();
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
const next = this.requestQueue.shift();
|
|
345
|
+
if (next) {
|
|
346
|
+
next();
|
|
211
347
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
348
|
+
else {
|
|
349
|
+
this.activeRequests -= 1;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
async withEndpointRequestSlot(endpointGroup, limit, callback) {
|
|
354
|
+
const active = this.endpointActiveRequests.get(endpointGroup) ?? 0;
|
|
355
|
+
if (active >= limit) {
|
|
356
|
+
const queue = this.endpointRequestQueues.get(endpointGroup) ?? [];
|
|
357
|
+
await new Promise((resolve) => {
|
|
358
|
+
queue.push(resolve);
|
|
359
|
+
this.endpointRequestQueues.set(endpointGroup, queue);
|
|
216
360
|
});
|
|
217
361
|
}
|
|
362
|
+
else {
|
|
363
|
+
this.endpointActiveRequests.set(endpointGroup, active + 1);
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
return await callback();
|
|
367
|
+
}
|
|
218
368
|
finally {
|
|
219
|
-
|
|
369
|
+
const queue = this.endpointRequestQueues.get(endpointGroup);
|
|
370
|
+
const next = queue?.shift();
|
|
371
|
+
if (next) {
|
|
372
|
+
next();
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
this.endpointActiveRequests.set(endpointGroup, Math.max(0, (this.endpointActiveRequests.get(endpointGroup) ?? 1) - 1));
|
|
376
|
+
}
|
|
377
|
+
if (queue?.length === 0) {
|
|
378
|
+
this.endpointRequestQueues.delete(endpointGroup);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
recordEndpointMetric(path, durationMs, status, failed) {
|
|
383
|
+
const endpoint = metricEndpoint(path);
|
|
384
|
+
const metric = this.endpointMetrics.get(endpoint) ??
|
|
385
|
+
{
|
|
386
|
+
requests: 0,
|
|
387
|
+
errors: 0,
|
|
388
|
+
totalMs: 0,
|
|
389
|
+
maxMs: 0,
|
|
390
|
+
lastMs: 0,
|
|
391
|
+
statuses: new Map()
|
|
392
|
+
};
|
|
393
|
+
const statusKey = status === undefined ? "network_error" : String(status);
|
|
394
|
+
metric.requests += 1;
|
|
395
|
+
metric.errors += failed ? 1 : 0;
|
|
396
|
+
metric.totalMs += durationMs;
|
|
397
|
+
metric.maxMs = Math.max(metric.maxMs, durationMs);
|
|
398
|
+
metric.lastMs = durationMs;
|
|
399
|
+
metric.statuses.set(statusKey, (metric.statuses.get(statusKey) ?? 0) + 1);
|
|
400
|
+
this.endpointMetrics.set(endpoint, metric);
|
|
401
|
+
}
|
|
402
|
+
reserveUpstreamRequest(requestId, bucket) {
|
|
403
|
+
const now = Date.now();
|
|
404
|
+
const state = this.rateLimitState(bucket);
|
|
405
|
+
this.refreshUpstreamRateLimitWindow(state, now);
|
|
406
|
+
this.refreshUpstreamRequestWindow(now);
|
|
407
|
+
if ((this.globalBlockedUntilMs ?? 0) > now || (state.blockedUntilMs ?? 0) > now) {
|
|
408
|
+
throw localRateLimitError(requestId, bucket, state, this.globalBlockedUntilMs, this.upstreamRequestTimestampsMs.length);
|
|
409
|
+
}
|
|
410
|
+
if (this.shouldEnforceSharedRequestWindow() &&
|
|
411
|
+
this.upstreamRequestTimestampsMs.length >= UPSTREAM_KEY_IP_REQUEST_LIMIT) {
|
|
412
|
+
this.globalBlockedUntilMs =
|
|
413
|
+
(this.upstreamRequestTimestampsMs[0] ?? now) + UPSTREAM_KEY_IP_WINDOW_MS;
|
|
414
|
+
throw localRateLimitError(requestId, bucket, state, this.globalBlockedUntilMs, this.upstreamRequestTimestampsMs.length);
|
|
415
|
+
}
|
|
416
|
+
if (state.remaining !== undefined) {
|
|
417
|
+
if (state.remaining <= 0) {
|
|
418
|
+
state.blockedUntilMs =
|
|
419
|
+
state.resetAtMs && state.resetAtMs > now
|
|
420
|
+
? state.resetAtMs
|
|
421
|
+
: now + DEFAULT_RATE_LIMIT_COOLDOWN_MS;
|
|
422
|
+
throw localRateLimitError(requestId, bucket, state, this.globalBlockedUntilMs, this.upstreamRequestTimestampsMs.length);
|
|
423
|
+
}
|
|
424
|
+
state.remaining -= 1;
|
|
425
|
+
}
|
|
426
|
+
this.upstreamRequestTimestampsMs.push(now);
|
|
427
|
+
}
|
|
428
|
+
observeUpstreamRateLimit(bucket, headers, status) {
|
|
429
|
+
const now = Date.now();
|
|
430
|
+
const state = this.rateLimitState(bucket);
|
|
431
|
+
const limit = readRateLimitNumber(headers, ["x-ratelimit-limit", "ratelimit-limit"]);
|
|
432
|
+
const remaining = readRateLimitNumber(headers, ["x-ratelimit-remaining", "ratelimit-remaining"]);
|
|
433
|
+
const resetAtMs = parseRateLimitReset(headers, now);
|
|
434
|
+
const retryAtMs = parseRetryAfter(headers, now);
|
|
435
|
+
const previousResetAtMs = state.resetAtMs;
|
|
436
|
+
const sameWindow = resetAtMs === undefined ||
|
|
437
|
+
previousResetAtMs === undefined ||
|
|
438
|
+
Math.abs(resetAtMs - previousResetAtMs) < 1_000;
|
|
439
|
+
if (limit !== undefined) {
|
|
440
|
+
state.limit = limit;
|
|
441
|
+
}
|
|
442
|
+
if (remaining !== undefined) {
|
|
443
|
+
state.remaining =
|
|
444
|
+
sameWindow && state.remaining !== undefined
|
|
445
|
+
? Math.min(state.remaining, remaining)
|
|
446
|
+
: remaining;
|
|
447
|
+
}
|
|
448
|
+
if (resetAtMs !== undefined) {
|
|
449
|
+
state.resetAtMs = resetAtMs;
|
|
220
450
|
}
|
|
451
|
+
state.observedAtMs = now;
|
|
452
|
+
state.lastStatus = status;
|
|
453
|
+
if (status === 429) {
|
|
454
|
+
const advertisedBlockUntil = Math.max(retryAtMs ?? 0, resetAtMs && resetAtMs > now ? resetAtMs : 0);
|
|
455
|
+
const blockedUntil = advertisedBlockUntil > now ? advertisedBlockUntil : now + DEFAULT_RATE_LIMIT_COOLDOWN_MS;
|
|
456
|
+
state.remaining = 0;
|
|
457
|
+
state.blockedUntilMs = blockedUntil;
|
|
458
|
+
this.globalBlockedUntilMs = Math.max(this.globalBlockedUntilMs ?? 0, blockedUntil);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (remaining === 0) {
|
|
462
|
+
state.blockedUntilMs =
|
|
463
|
+
resetAtMs && resetAtMs > now ? resetAtMs : now + DEFAULT_RATE_LIMIT_COOLDOWN_MS;
|
|
464
|
+
}
|
|
465
|
+
else if ((state.blockedUntilMs ?? 0) <= now) {
|
|
466
|
+
state.blockedUntilMs = undefined;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
rateLimitState(bucket) {
|
|
470
|
+
const state = this.upstreamRateLimits.get(bucket) ?? {};
|
|
471
|
+
this.upstreamRateLimits.set(bucket, state);
|
|
472
|
+
return state;
|
|
473
|
+
}
|
|
474
|
+
refreshUpstreamRequestWindow(now) {
|
|
475
|
+
const cutoff = now - UPSTREAM_KEY_IP_WINDOW_MS;
|
|
476
|
+
while (this.upstreamRequestTimestampsMs.length > 0 &&
|
|
477
|
+
(this.upstreamRequestTimestampsMs[0] ?? now) <= cutoff) {
|
|
478
|
+
this.upstreamRequestTimestampsMs.shift();
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
upstreamRequestWindowDiagnostics() {
|
|
482
|
+
const now = Date.now();
|
|
483
|
+
this.refreshUpstreamRequestWindow(now);
|
|
484
|
+
const enforced = this.shouldEnforceSharedRequestWindow();
|
|
485
|
+
return {
|
|
486
|
+
enforced,
|
|
487
|
+
limit: UPSTREAM_KEY_IP_REQUEST_LIMIT,
|
|
488
|
+
window_ms: UPSTREAM_KEY_IP_WINDOW_MS,
|
|
489
|
+
used: this.upstreamRequestTimestampsMs.length,
|
|
490
|
+
remaining: enforced
|
|
491
|
+
? Math.max(0, UPSTREAM_KEY_IP_REQUEST_LIMIT - this.upstreamRequestTimestampsMs.length)
|
|
492
|
+
: undefined,
|
|
493
|
+
resets_at: toIsoString(this.upstreamRequestTimestampsMs.length > 0
|
|
494
|
+
? (this.upstreamRequestTimestampsMs[0] ?? now) + UPSTREAM_KEY_IP_WINDOW_MS
|
|
495
|
+
: undefined)
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
shouldEnforceSharedRequestWindow() {
|
|
499
|
+
try {
|
|
500
|
+
const hostname = new URL(this.config.apiBaseUrl).hostname.toLowerCase();
|
|
501
|
+
if (hostname === "bbradar.io" || hostname.endsWith(".bbradar.io")) {
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
// Invalid base URLs fail when a request URL is constructed.
|
|
507
|
+
}
|
|
508
|
+
return [...this.upstreamRateLimits.values()].some((state) => state.limit !== undefined ||
|
|
509
|
+
state.remaining !== undefined ||
|
|
510
|
+
state.resetAtMs !== undefined);
|
|
511
|
+
}
|
|
512
|
+
refreshUpstreamRateLimitWindow(state, now) {
|
|
513
|
+
if (state.resetAtMs !== undefined && state.resetAtMs <= now) {
|
|
514
|
+
state.remaining = undefined;
|
|
515
|
+
state.resetAtMs = undefined;
|
|
516
|
+
}
|
|
517
|
+
if (state.blockedUntilMs !== undefined && state.blockedUntilMs <= now) {
|
|
518
|
+
state.blockedUntilMs = undefined;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
upstreamRateLimitDiagnostics() {
|
|
522
|
+
const now = Date.now();
|
|
523
|
+
const entries = [...this.upstreamRateLimits.entries()];
|
|
524
|
+
for (const [, state] of entries) {
|
|
525
|
+
this.refreshUpstreamRateLimitWindow(state, now);
|
|
526
|
+
}
|
|
527
|
+
if (this.globalBlockedUntilMs !== undefined && this.globalBlockedUntilMs <= now) {
|
|
528
|
+
this.globalBlockedUntilMs = undefined;
|
|
529
|
+
}
|
|
530
|
+
const observedEntries = entries.filter(([, state]) => state.observedAtMs !== undefined);
|
|
531
|
+
const latest = [...observedEntries].sort((left, right) => (right[1].observedAtMs ?? 0) - (left[1].observedAtMs ?? 0))[0];
|
|
532
|
+
const latestState = latest?.[1];
|
|
533
|
+
const globallyBlocked = (this.globalBlockedUntilMs ?? 0) > now;
|
|
534
|
+
const anyBucketBlocked = observedEntries.some(([, state]) => (state.blockedUntilMs ?? 0) > now);
|
|
535
|
+
const blockedUntil = Math.max(this.globalBlockedUntilMs ?? 0, ...observedEntries.map(([, state]) => state.blockedUntilMs ?? 0));
|
|
536
|
+
return {
|
|
537
|
+
observed: observedEntries.length > 0,
|
|
538
|
+
bucket: latest?.[0],
|
|
539
|
+
limit: latestState?.limit,
|
|
540
|
+
remaining: latestState?.remaining,
|
|
541
|
+
reset_at: toIsoString(latestState?.resetAtMs),
|
|
542
|
+
blocked: globallyBlocked || anyBucketBlocked,
|
|
543
|
+
globally_blocked: globallyBlocked,
|
|
544
|
+
blocked_until: toIsoString(blockedUntil > 0 ? blockedUntil : undefined),
|
|
545
|
+
observed_at: toIsoString(latestState?.observedAtMs),
|
|
546
|
+
last_status: latestState?.lastStatus,
|
|
547
|
+
buckets: Object.fromEntries(observedEntries.map(([bucket, state]) => [
|
|
548
|
+
bucket,
|
|
549
|
+
{
|
|
550
|
+
limit: state.limit,
|
|
551
|
+
remaining: state.remaining,
|
|
552
|
+
reset_at: toIsoString(state.resetAtMs),
|
|
553
|
+
blocked: (state.blockedUntilMs ?? 0) > now,
|
|
554
|
+
blocked_until: toIsoString(state.blockedUntilMs),
|
|
555
|
+
observed_at: toIsoString(state.observedAtMs),
|
|
556
|
+
last_status: state.lastStatus
|
|
557
|
+
}
|
|
558
|
+
]))
|
|
559
|
+
};
|
|
221
560
|
}
|
|
222
561
|
buildUrl(path, query) {
|
|
223
562
|
const url = new URL(`${this.config.apiBaseUrl}${path}`);
|
|
@@ -227,20 +566,19 @@ export class BBRadarClient {
|
|
|
227
566
|
}
|
|
228
567
|
if (Array.isArray(value)) {
|
|
229
568
|
if (value.length > 0) {
|
|
230
|
-
url.searchParams.set(key, value.join(","));
|
|
569
|
+
url.searchParams.set(key, [...value].map(String).sort().join(","));
|
|
231
570
|
}
|
|
232
571
|
continue;
|
|
233
572
|
}
|
|
234
573
|
url.searchParams.set(key, String(value));
|
|
235
574
|
}
|
|
575
|
+
url.searchParams.sort();
|
|
236
576
|
return url;
|
|
237
577
|
}
|
|
238
578
|
headers(body) {
|
|
239
579
|
const headers = {
|
|
240
580
|
authorization: `Bearer ${this.config.apiKey}`,
|
|
241
581
|
accept: "application/json",
|
|
242
|
-
"cache-control": "no-cache, no-store, max-age=0",
|
|
243
|
-
pragma: "no-cache",
|
|
244
582
|
"user-agent": "@bbradar/mcp"
|
|
245
583
|
};
|
|
246
584
|
if (body !== undefined) {
|
|
@@ -249,6 +587,110 @@ export class BBRadarClient {
|
|
|
249
587
|
return headers;
|
|
250
588
|
}
|
|
251
589
|
}
|
|
590
|
+
function localRateLimitError(requestId, bucket, state, globalBlockedUntilMs, upstreamWindowUsed) {
|
|
591
|
+
return new BBRadarApiError({
|
|
592
|
+
message: "BBRadar API quota is temporarily exhausted; no upstream request was sent.",
|
|
593
|
+
requestId,
|
|
594
|
+
status: 429,
|
|
595
|
+
detail: {
|
|
596
|
+
source: "local_rate_limit_guard",
|
|
597
|
+
bucket,
|
|
598
|
+
limit: state.limit,
|
|
599
|
+
remaining: state.remaining,
|
|
600
|
+
reset_at: toIsoString(state.resetAtMs),
|
|
601
|
+
retry_at: toIsoString(Math.max(state.blockedUntilMs ?? 0, globalBlockedUntilMs ?? 0)),
|
|
602
|
+
shared_key_ip_window: {
|
|
603
|
+
limit: UPSTREAM_KEY_IP_REQUEST_LIMIT,
|
|
604
|
+
used: upstreamWindowUsed,
|
|
605
|
+
remaining: Math.max(0, UPSTREAM_KEY_IP_REQUEST_LIMIT - upstreamWindowUsed)
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
function readRateLimitNumber(headers, names) {
|
|
611
|
+
for (const name of names) {
|
|
612
|
+
const value = headers.get(name);
|
|
613
|
+
if (value === null) {
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
const parsed = Number(value.trim());
|
|
617
|
+
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
618
|
+
return Math.floor(parsed);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
function parseRateLimitReset(headers, now) {
|
|
624
|
+
const value = headers.get("x-ratelimit-reset") ?? headers.get("ratelimit-reset");
|
|
625
|
+
if (!value) {
|
|
626
|
+
return undefined;
|
|
627
|
+
}
|
|
628
|
+
const numeric = Number(value.trim());
|
|
629
|
+
if (Number.isFinite(numeric) && numeric >= 0) {
|
|
630
|
+
if (numeric >= 1_000_000_000_000) {
|
|
631
|
+
return numeric;
|
|
632
|
+
}
|
|
633
|
+
if (numeric >= 1_000_000_000) {
|
|
634
|
+
return numeric * 1_000;
|
|
635
|
+
}
|
|
636
|
+
return now + numeric * 1_000;
|
|
637
|
+
}
|
|
638
|
+
const timestamp = Date.parse(value);
|
|
639
|
+
return Number.isFinite(timestamp) ? timestamp : undefined;
|
|
640
|
+
}
|
|
641
|
+
function parseRetryAfter(headers, now) {
|
|
642
|
+
const value = headers.get("retry-after");
|
|
643
|
+
if (!value) {
|
|
644
|
+
return undefined;
|
|
645
|
+
}
|
|
646
|
+
const seconds = Number(value.trim());
|
|
647
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
648
|
+
return now + seconds * 1_000;
|
|
649
|
+
}
|
|
650
|
+
const timestamp = Date.parse(value);
|
|
651
|
+
return Number.isFinite(timestamp) ? timestamp : undefined;
|
|
652
|
+
}
|
|
653
|
+
function toIsoString(timestamp) {
|
|
654
|
+
return timestamp === undefined || !Number.isFinite(timestamp) ? undefined : new Date(timestamp).toISOString();
|
|
655
|
+
}
|
|
656
|
+
function metricEndpoint(path) {
|
|
657
|
+
if (/^\/pro\/programs\/.+\/targets$/.test(path)) {
|
|
658
|
+
return "/pro/programs/:program_id/targets";
|
|
659
|
+
}
|
|
660
|
+
if (/^\/pro\/programs\/.+/.test(path)) {
|
|
661
|
+
return "/pro/programs/:program_id";
|
|
662
|
+
}
|
|
663
|
+
if (/^\/pro\/opportunities\/[^/]+$/.test(path)) {
|
|
664
|
+
return "/pro/opportunities/:level";
|
|
665
|
+
}
|
|
666
|
+
return path;
|
|
667
|
+
}
|
|
668
|
+
function rateLimitEndpointGroup(path) {
|
|
669
|
+
if (path === "/pro/targets/changes") {
|
|
670
|
+
return "target_changes";
|
|
671
|
+
}
|
|
672
|
+
if (/^\/pro\/programs\/.+\/targets$/.test(path)) {
|
|
673
|
+
return "program_targets";
|
|
674
|
+
}
|
|
675
|
+
if (path === "/pro/programs" ||
|
|
676
|
+
/^\/pro\/programs\/.+/.test(path) ||
|
|
677
|
+
/^\/pro\/opportunities\/[^/]+$/.test(path)) {
|
|
678
|
+
return "programs_and_opportunities";
|
|
679
|
+
}
|
|
680
|
+
if (path === "/pro/targets/search") {
|
|
681
|
+
return "target_search";
|
|
682
|
+
}
|
|
683
|
+
if (path === "/pro/targets/export") {
|
|
684
|
+
return "target_export";
|
|
685
|
+
}
|
|
686
|
+
return "other";
|
|
687
|
+
}
|
|
688
|
+
function endpointConcurrencyLimit(endpointGroup) {
|
|
689
|
+
return endpointGroup === "target_changes" ? TARGET_CHANGES_CONCURRENCY_LIMIT : undefined;
|
|
690
|
+
}
|
|
691
|
+
function isNegativeCacheStatus(status) {
|
|
692
|
+
return status === 404 || status === 405;
|
|
693
|
+
}
|
|
252
694
|
async function parseResponseBody(response) {
|
|
253
695
|
if (response.status === 204) {
|
|
254
696
|
return null;
|
|
@@ -290,7 +732,7 @@ function formatApiErrorMessage(status, data) {
|
|
|
290
732
|
: "Request was rejected.";
|
|
291
733
|
return `BBRadar API returned ${status}: ${suffix}`;
|
|
292
734
|
}
|
|
293
|
-
function cloneApiError(error, requestId) {
|
|
735
|
+
function cloneApiError(error, requestId, coalesced = error instanceof BBRadarApiError && error.coalesced) {
|
|
294
736
|
if (!(error instanceof BBRadarApiError)) {
|
|
295
737
|
return error;
|
|
296
738
|
}
|
|
@@ -301,6 +743,9 @@ function cloneApiError(error, requestId) {
|
|
|
301
743
|
status: error.status,
|
|
302
744
|
detail: error.detail,
|
|
303
745
|
errors: error.errors,
|
|
746
|
+
cached: error.cached,
|
|
747
|
+
coalesced,
|
|
748
|
+
cacheExpiresAt: error.cacheExpiresAt,
|
|
304
749
|
cause: error
|
|
305
750
|
});
|
|
306
751
|
}
|