@bbradar/mcp 0.1.19 → 0.2.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/README.md +74 -2
- package/benchmark/mcp-benchmark.mjs +167 -0
- package/dist/bbradarClient.d.ts +158 -3
- package/dist/bbradarClient.js +797 -57
- package/dist/bbradarClient.js.map +1 -1
- package/dist/cache.d.ts +13 -2
- package/dist/cache.js +36 -7
- package/dist/cache.js.map +1 -1
- package/dist/config.d.ts +11 -0
- package/dist/config.js +53 -6
- package/dist/config.js.map +1 -1
- package/dist/cursorStore.d.ts +33 -0
- package/dist/cursorStore.js +123 -0
- package/dist/cursorStore.js.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/sanitize.d.ts +1 -0
- package/dist/sanitize.js +164 -7
- package/dist/sanitize.js.map +1 -1
- package/dist/server.js +1119 -161
- package/dist/server.js.map +1 -1
- package/package.json +4 -2
package/dist/bbradarClient.js
CHANGED
|
@@ -2,12 +2,26 @@ 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 MAX_UPSTREAM_RATE_LIMIT_DELAY_MS = 10 * 60_000;
|
|
7
|
+
const CHANGE_FEED_CONCURRENCY_LIMIT = 2;
|
|
8
|
+
const TARGET_EXPORT_CONCURRENCY_LIMIT = 2;
|
|
9
|
+
const UPSTREAM_KEY_IP_REQUEST_LIMIT = 60;
|
|
10
|
+
const UPSTREAM_KEY_IP_WINDOW_MS = 60_000;
|
|
11
|
+
const MAX_RESPONSE_BODY_BYTES = 16 * 1024 * 1024;
|
|
12
|
+
const MAX_REQUEST_BODY_BYTES = 64 * 1024;
|
|
13
|
+
const DEFAULT_PROGRAM_SWR_MS = 5 * 60_000;
|
|
14
|
+
const DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 3;
|
|
15
|
+
const DEFAULT_CIRCUIT_COOLDOWN_MS = 30_000;
|
|
5
16
|
export class BBRadarApiError extends Error {
|
|
6
17
|
requestId;
|
|
7
18
|
upstreamRequestId;
|
|
8
19
|
status;
|
|
9
20
|
detail;
|
|
10
21
|
errors;
|
|
22
|
+
cached;
|
|
23
|
+
coalesced;
|
|
24
|
+
cacheExpiresAt;
|
|
11
25
|
constructor(params) {
|
|
12
26
|
super(params.message, { cause: params.cause });
|
|
13
27
|
this.name = "BBRadarApiError";
|
|
@@ -16,33 +30,98 @@ export class BBRadarApiError extends Error {
|
|
|
16
30
|
this.status = params.status;
|
|
17
31
|
this.detail = params.detail;
|
|
18
32
|
this.errors = params.errors;
|
|
33
|
+
this.cached = params.cached ?? false;
|
|
34
|
+
this.coalesced = params.coalesced ?? false;
|
|
35
|
+
this.cacheExpiresAt = params.cacheExpiresAt;
|
|
19
36
|
}
|
|
20
37
|
}
|
|
21
38
|
export class BBRadarClient {
|
|
22
39
|
config;
|
|
23
40
|
cache;
|
|
41
|
+
negativeCache;
|
|
24
42
|
inFlight = new Map();
|
|
43
|
+
endpointMetrics = new Map();
|
|
44
|
+
requestQueue = [];
|
|
45
|
+
endpointRequestQueues = new Map();
|
|
46
|
+
endpointActiveRequests = new Map();
|
|
47
|
+
upstreamRateLimits = new Map();
|
|
48
|
+
circuitBreakers = new Map();
|
|
49
|
+
upstreamRequestTimestampsMs = [];
|
|
50
|
+
activeRequests = 0;
|
|
51
|
+
globalBlockedUntilMs;
|
|
52
|
+
capabilityNegotiation;
|
|
53
|
+
capabilityState = { status: "not_started" };
|
|
25
54
|
constructor(config) {
|
|
26
55
|
this.config = config;
|
|
27
|
-
this.cache = new TtlCache(config.cacheMaxEntries, config.cacheTtlMs);
|
|
56
|
+
this.cache = new TtlCache(config.cacheMaxEntries, config.cacheTtlMs, config.programStaleWhileRevalidateMs ?? DEFAULT_PROGRAM_SWR_MS);
|
|
57
|
+
this.negativeCache = new TtlCache(config.cacheMaxEntries, config.negativeCacheTtlMs);
|
|
28
58
|
}
|
|
29
59
|
diagnostics() {
|
|
60
|
+
const endpointEntries = [...this.endpointMetrics.entries()];
|
|
61
|
+
const totalRequests = endpointEntries.reduce((total, [, metric]) => total + metric.requests, 0);
|
|
62
|
+
const totalErrors = endpointEntries.reduce((total, [, metric]) => total + metric.errors, 0);
|
|
63
|
+
const totalMs = endpointEntries.reduce((total, [, metric]) => total + metric.totalMs, 0);
|
|
30
64
|
return {
|
|
31
65
|
response_cache: {
|
|
32
66
|
enabled: this.config.cacheTtlMs > 0 && this.config.cacheMaxEntries > 0,
|
|
33
67
|
...this.cache.stats()
|
|
34
68
|
},
|
|
35
|
-
|
|
69
|
+
negative_response_cache: {
|
|
70
|
+
enabled: this.config.negativeCacheTtlMs > 0 && this.config.cacheMaxEntries > 0,
|
|
71
|
+
...this.negativeCache.stats()
|
|
72
|
+
},
|
|
73
|
+
capability_negotiation: this.capabilitySnapshot(),
|
|
74
|
+
circuit_breakers: this.circuitBreakerDiagnostics(),
|
|
75
|
+
in_flight_requests: this.inFlight.size,
|
|
76
|
+
request_concurrency: {
|
|
77
|
+
limit: this.config.maxConcurrentRequests,
|
|
78
|
+
active: this.activeRequests,
|
|
79
|
+
queued: this.requestQueue.length,
|
|
80
|
+
endpoint_limits: {
|
|
81
|
+
change_feeds: {
|
|
82
|
+
limit: CHANGE_FEED_CONCURRENCY_LIMIT,
|
|
83
|
+
active: this.endpointActiveRequests.get("change_feeds") ?? 0,
|
|
84
|
+
queued: this.endpointRequestQueues.get("change_feeds")?.length ?? 0
|
|
85
|
+
},
|
|
86
|
+
target_export: {
|
|
87
|
+
limit: TARGET_EXPORT_CONCURRENCY_LIMIT,
|
|
88
|
+
active: this.endpointActiveRequests.get("target_export") ?? 0,
|
|
89
|
+
queued: this.endpointRequestQueues.get("target_export")?.length ?? 0
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
upstream_request_window: this.upstreamRequestWindowDiagnostics(),
|
|
94
|
+
upstream_rate_limit: this.upstreamRateLimitDiagnostics(),
|
|
95
|
+
upstream_requests: {
|
|
96
|
+
actual_requests: totalRequests,
|
|
97
|
+
errors: totalErrors,
|
|
98
|
+
avg_ms: totalRequests > 0 ? Math.round(totalMs / totalRequests) : 0,
|
|
99
|
+
max_ms: endpointEntries.reduce((maximum, [, metric]) => Math.max(maximum, metric.maxMs), 0),
|
|
100
|
+
by_endpoint: Object.fromEntries(endpointEntries.map(([endpoint, metric]) => [
|
|
101
|
+
endpoint,
|
|
102
|
+
{
|
|
103
|
+
requests: metric.requests,
|
|
104
|
+
errors: metric.errors,
|
|
105
|
+
avg_ms: metric.requests > 0 ? Math.round(metric.totalMs / metric.requests) : 0,
|
|
106
|
+
max_ms: metric.maxMs,
|
|
107
|
+
last_ms: metric.lastMs,
|
|
108
|
+
statuses: Object.fromEntries(metric.statuses)
|
|
109
|
+
}
|
|
110
|
+
]))
|
|
111
|
+
}
|
|
36
112
|
};
|
|
37
113
|
}
|
|
38
114
|
async validateKey() {
|
|
39
115
|
try {
|
|
40
|
-
await this.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
116
|
+
await this.negotiateCapabilities();
|
|
117
|
+
if (this.capabilityState.status === "legacy") {
|
|
118
|
+
await this.request({
|
|
119
|
+
path: "/pro/programs",
|
|
120
|
+
query: { page: 1, page_size: 1 },
|
|
121
|
+
cache: false,
|
|
122
|
+
coalesce: false
|
|
123
|
+
});
|
|
124
|
+
}
|
|
46
125
|
}
|
|
47
126
|
catch (error) {
|
|
48
127
|
if (!(error instanceof BBRadarApiError)) {
|
|
@@ -63,26 +142,105 @@ export class BBRadarClient {
|
|
|
63
142
|
throw error;
|
|
64
143
|
}
|
|
65
144
|
}
|
|
145
|
+
negotiateCapabilities() {
|
|
146
|
+
if (!this.capabilityNegotiation) {
|
|
147
|
+
this.capabilityNegotiation = this.fetchCapabilityContract();
|
|
148
|
+
}
|
|
149
|
+
return this.capabilityNegotiation;
|
|
150
|
+
}
|
|
151
|
+
capabilitySnapshot() {
|
|
152
|
+
return { ...this.capabilityState };
|
|
153
|
+
}
|
|
154
|
+
supportsEndpoint(endpoint) {
|
|
155
|
+
if (this.capabilityState.status !== "negotiated") {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
const contract = asRecord(this.capabilityState.contract);
|
|
159
|
+
const endpoints = asRecord(contract?.endpoints);
|
|
160
|
+
return endpoints !== undefined && asRecord(endpoints[endpoint]) !== undefined;
|
|
161
|
+
}
|
|
162
|
+
endpointCapability(endpoint) {
|
|
163
|
+
if (this.capabilityState.status !== "negotiated") {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
const contract = asRecord(this.capabilityState.contract);
|
|
167
|
+
return asRecord(asRecord(contract?.endpoints)?.[endpoint]);
|
|
168
|
+
}
|
|
169
|
+
endpointNumber(endpoint, field, fallback) {
|
|
170
|
+
const value = this.endpointCapability(endpoint)?.[field];
|
|
171
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
172
|
+
}
|
|
173
|
+
endpointStringArray(endpoint, field, fallback) {
|
|
174
|
+
const value = this.endpointCapability(endpoint)?.[field];
|
|
175
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string")
|
|
176
|
+
? [...value]
|
|
177
|
+
: [...fallback];
|
|
178
|
+
}
|
|
179
|
+
async fetchCapabilityContract() {
|
|
180
|
+
try {
|
|
181
|
+
const result = await this.getCapabilities({ cache: false, coalesce: false });
|
|
182
|
+
this.capabilityState = {
|
|
183
|
+
status: "negotiated",
|
|
184
|
+
negotiatedAt: result.fetchedAt,
|
|
185
|
+
contract: result.data
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (error instanceof BBRadarApiError && (error.status === 404 || error.status === 405)) {
|
|
190
|
+
this.capabilityState = {
|
|
191
|
+
status: "legacy",
|
|
192
|
+
negotiatedAt: new Date().toISOString()
|
|
193
|
+
};
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
this.capabilityState = {
|
|
197
|
+
status: "failed",
|
|
198
|
+
negotiatedAt: new Date().toISOString(),
|
|
199
|
+
error: error instanceof Error ? error.message : String(error)
|
|
200
|
+
};
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
66
204
|
listPrograms(query) {
|
|
67
205
|
return this.request({
|
|
68
206
|
path: "/pro/programs",
|
|
69
207
|
query
|
|
70
208
|
});
|
|
71
209
|
}
|
|
210
|
+
getCapabilities(options = {}) {
|
|
211
|
+
return this.request({
|
|
212
|
+
path: "/pro/capabilities",
|
|
213
|
+
...options
|
|
214
|
+
});
|
|
215
|
+
}
|
|
72
216
|
getProgram(programId) {
|
|
73
217
|
return this.request({
|
|
74
|
-
path: `/pro/programs/${encodeProgramIdPath(programId)}
|
|
218
|
+
path: `/pro/programs/${encodeProgramIdPath(programId)}`,
|
|
219
|
+
staleWhileRevalidateMs: this.config.programStaleWhileRevalidateMs ?? DEFAULT_PROGRAM_SWR_MS
|
|
75
220
|
});
|
|
76
221
|
}
|
|
77
|
-
getProgramTargets(programId) {
|
|
222
|
+
getProgramTargets(programId, query = {}) {
|
|
78
223
|
return this.request({
|
|
79
|
-
path: `/pro/programs/${encodeProgramIdPath(programId)}/targets
|
|
224
|
+
path: `/pro/programs/${encodeProgramIdPath(programId)}/targets`,
|
|
225
|
+
query: {
|
|
226
|
+
page: 1,
|
|
227
|
+
page_size: this.endpointNumber("program_targets", "max_page_size", 500),
|
|
228
|
+
...query
|
|
229
|
+
}
|
|
80
230
|
});
|
|
81
231
|
}
|
|
82
232
|
getRecentChanges(query) {
|
|
83
233
|
return this.request({
|
|
84
234
|
path: "/pro/targets/changes",
|
|
85
|
-
query
|
|
235
|
+
query,
|
|
236
|
+
cache: query.after_id === undefined
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
getRepositoryChanges(query) {
|
|
240
|
+
return this.request({
|
|
241
|
+
path: "/pro/repositories/changes",
|
|
242
|
+
query,
|
|
243
|
+
cache: query.after_id === undefined
|
|
86
244
|
});
|
|
87
245
|
}
|
|
88
246
|
searchTargets(query) {
|
|
@@ -116,18 +274,37 @@ export class BBRadarClient {
|
|
|
116
274
|
body: options.body ?? null
|
|
117
275
|
});
|
|
118
276
|
const cacheable = method === "GET" && (options.cache ?? true) && this.config.cacheTtlMs > 0 && this.config.cacheMaxEntries > 0;
|
|
277
|
+
const negativeCacheable = method === "GET" &&
|
|
278
|
+
(options.cache ?? true) &&
|
|
279
|
+
this.config.negativeCacheTtlMs > 0 &&
|
|
280
|
+
this.config.cacheMaxEntries > 0;
|
|
119
281
|
const coalesceable = method === "GET" && (options.coalesce ?? true);
|
|
120
282
|
if (cacheable) {
|
|
121
|
-
const cached = this.cache.
|
|
283
|
+
const cached = this.cache.getWithStale(requestKey, options.staleWhileRevalidateMs ?? 0);
|
|
122
284
|
if (cached) {
|
|
285
|
+
if (cached.stale) {
|
|
286
|
+
this.refreshInBackground(options, method, url, requestKey);
|
|
287
|
+
}
|
|
123
288
|
return {
|
|
124
289
|
...cached.value,
|
|
125
290
|
requestId,
|
|
126
291
|
cached: true,
|
|
292
|
+
stale: cached.stale,
|
|
127
293
|
cacheExpiresAt: new Date(cached.expiresAt).toISOString()
|
|
128
294
|
};
|
|
129
295
|
}
|
|
130
296
|
}
|
|
297
|
+
if (negativeCacheable) {
|
|
298
|
+
const cachedError = this.negativeCache.get(requestKey);
|
|
299
|
+
if (cachedError) {
|
|
300
|
+
throw new BBRadarApiError({
|
|
301
|
+
...cachedError.value,
|
|
302
|
+
requestId,
|
|
303
|
+
cached: true,
|
|
304
|
+
cacheExpiresAt: new Date(cachedError.expiresAt).toISOString()
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
131
308
|
if (coalesceable) {
|
|
132
309
|
const pending = this.inFlight.get(requestKey);
|
|
133
310
|
if (pending) {
|
|
@@ -141,7 +318,7 @@ export class BBRadarClient {
|
|
|
141
318
|
};
|
|
142
319
|
}
|
|
143
320
|
catch (error) {
|
|
144
|
-
throw cloneApiError(error, requestId);
|
|
321
|
+
throw cloneApiError(error, requestId, true);
|
|
145
322
|
}
|
|
146
323
|
}
|
|
147
324
|
}
|
|
@@ -154,12 +331,30 @@ export class BBRadarClient {
|
|
|
154
331
|
if (cacheable) {
|
|
155
332
|
this.cache.set(requestKey, result);
|
|
156
333
|
}
|
|
334
|
+
if (negativeCacheable) {
|
|
335
|
+
this.negativeCache.delete(requestKey);
|
|
336
|
+
}
|
|
157
337
|
return {
|
|
158
338
|
...result,
|
|
159
339
|
requestId,
|
|
160
340
|
cached: false
|
|
161
341
|
};
|
|
162
342
|
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
if (negativeCacheable &&
|
|
345
|
+
error instanceof BBRadarApiError &&
|
|
346
|
+
error.status !== undefined &&
|
|
347
|
+
isNegativeCacheStatus(error.status)) {
|
|
348
|
+
this.negativeCache.set(requestKey, {
|
|
349
|
+
message: error.message,
|
|
350
|
+
upstreamRequestId: error.upstreamRequestId,
|
|
351
|
+
status: error.status,
|
|
352
|
+
detail: error.detail,
|
|
353
|
+
errors: error.errors
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
163
358
|
finally {
|
|
164
359
|
if (coalesceable) {
|
|
165
360
|
this.inFlight.delete(requestKey);
|
|
@@ -167,58 +362,430 @@ export class BBRadarClient {
|
|
|
167
362
|
}
|
|
168
363
|
}
|
|
169
364
|
async fetchFromApi(options, method, url, requestId) {
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
cache: "no-store",
|
|
177
|
-
signal: controller.signal
|
|
178
|
-
};
|
|
179
|
-
if (options.body !== undefined) {
|
|
180
|
-
init.body = JSON.stringify(options.body);
|
|
181
|
-
}
|
|
182
|
-
const response = await fetch(url, init);
|
|
183
|
-
const upstreamRequestId = extractRequestId(response.headers);
|
|
184
|
-
const data = await parseResponseBody(response);
|
|
185
|
-
if (!response.ok) {
|
|
186
|
-
const params = {
|
|
187
|
-
message: formatApiErrorMessage(response.status, data),
|
|
365
|
+
const serializedBody = options.body === undefined ? undefined : JSON.stringify(options.body);
|
|
366
|
+
if (serializedBody !== undefined) {
|
|
367
|
+
const bodyBytes = Buffer.byteLength(serializedBody, "utf8");
|
|
368
|
+
if (bodyBytes > MAX_REQUEST_BODY_BYTES) {
|
|
369
|
+
throw new BBRadarApiError({
|
|
370
|
+
message: `BBRadar API request body exceeds the ${MAX_REQUEST_BODY_BYTES}-byte limit.`,
|
|
188
371
|
requestId,
|
|
372
|
+
status: 413,
|
|
373
|
+
detail: { body_bytes: bodyBytes, max_body_bytes: MAX_REQUEST_BODY_BYTES }
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return this.withRequestSlot(options.path, async () => {
|
|
378
|
+
const rateLimitBucket = rateLimitEndpointGroup(options.path);
|
|
379
|
+
this.assertCircuitClosed(requestId, rateLimitBucket);
|
|
380
|
+
this.reserveUpstreamRequest(requestId, rateLimitBucket);
|
|
381
|
+
const startedAt = Date.now();
|
|
382
|
+
const controller = new AbortController();
|
|
383
|
+
const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
|
|
384
|
+
let status;
|
|
385
|
+
let failed = true;
|
|
386
|
+
try {
|
|
387
|
+
const init = {
|
|
388
|
+
method,
|
|
389
|
+
headers: this.headers(options.body),
|
|
390
|
+
signal: controller.signal
|
|
391
|
+
};
|
|
392
|
+
if (serializedBody !== undefined) {
|
|
393
|
+
init.body = serializedBody;
|
|
394
|
+
}
|
|
395
|
+
const response = await fetch(url, init);
|
|
396
|
+
status = response.status;
|
|
397
|
+
this.observeCircuitResponse(rateLimitBucket, response.status);
|
|
398
|
+
this.observeUpstreamRateLimit(rateLimitBucket, response.headers, response.status);
|
|
399
|
+
const upstreamRequestId = extractRequestId(response.headers);
|
|
400
|
+
const data = await parseResponseBody(response);
|
|
401
|
+
if (!response.ok) {
|
|
402
|
+
this.observeUpstreamErrorBody(rateLimitBucket, response.status, data);
|
|
403
|
+
const params = {
|
|
404
|
+
message: formatApiErrorMessage(response.status, data),
|
|
405
|
+
requestId,
|
|
406
|
+
status: response.status,
|
|
407
|
+
detail: extractObjectField(data, "detail"),
|
|
408
|
+
errors: extractObjectField(data, "errors")
|
|
409
|
+
};
|
|
410
|
+
if (upstreamRequestId) {
|
|
411
|
+
params.upstreamRequestId = upstreamRequestId;
|
|
412
|
+
}
|
|
413
|
+
throw new BBRadarApiError(params);
|
|
414
|
+
}
|
|
415
|
+
const result = {
|
|
416
|
+
data: data,
|
|
189
417
|
status: response.status,
|
|
190
|
-
|
|
191
|
-
errors: extractObjectField(data, "errors")
|
|
418
|
+
fetchedAt: new Date().toISOString()
|
|
192
419
|
};
|
|
193
420
|
if (upstreamRequestId) {
|
|
194
|
-
|
|
421
|
+
result.upstreamRequestId = upstreamRequestId;
|
|
195
422
|
}
|
|
196
|
-
|
|
423
|
+
failed = false;
|
|
424
|
+
return result;
|
|
197
425
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
426
|
+
catch (error) {
|
|
427
|
+
if (error instanceof BBRadarApiError) {
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
430
|
+
this.observeCircuitFailure(rateLimitBucket);
|
|
431
|
+
throw new BBRadarApiError({
|
|
432
|
+
message: error instanceof Error && error.name === "AbortError" ? "BBRadar API request timed out." : "BBRadar API request failed.",
|
|
433
|
+
requestId,
|
|
434
|
+
cause: error
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
finally {
|
|
438
|
+
clearTimeout(timeout);
|
|
439
|
+
this.recordEndpointMetric(options.path, Date.now() - startedAt, status, failed);
|
|
205
440
|
}
|
|
206
|
-
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
refreshInBackground(options, method, url, requestKey) {
|
|
444
|
+
if (this.inFlight.has(requestKey)) {
|
|
445
|
+
return;
|
|
207
446
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
447
|
+
const refresh = this.fetchFromApi({ ...options, staleWhileRevalidateMs: 0 }, method, url, randomUUID());
|
|
448
|
+
this.inFlight.set(requestKey, refresh);
|
|
449
|
+
void refresh
|
|
450
|
+
.then((result) => {
|
|
451
|
+
this.cache.set(requestKey, result);
|
|
452
|
+
this.negativeCache.delete(requestKey);
|
|
453
|
+
})
|
|
454
|
+
.catch(() => undefined)
|
|
455
|
+
.finally(() => {
|
|
456
|
+
if (this.inFlight.get(requestKey) === refresh) {
|
|
457
|
+
this.inFlight.delete(requestKey);
|
|
211
458
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
async withRequestSlot(path, callback) {
|
|
462
|
+
const endpointGroup = concurrencyEndpointGroup(path);
|
|
463
|
+
if (endpointGroup === undefined) {
|
|
464
|
+
return this.withGlobalRequestSlot(callback);
|
|
465
|
+
}
|
|
466
|
+
const endpointLimit = endpointConcurrencyLimit(endpointGroup);
|
|
467
|
+
if (endpointLimit === undefined) {
|
|
468
|
+
return this.withGlobalRequestSlot(callback);
|
|
469
|
+
}
|
|
470
|
+
return this.withEndpointRequestSlot(endpointGroup, endpointLimit, () => this.withGlobalRequestSlot(callback));
|
|
471
|
+
}
|
|
472
|
+
async withGlobalRequestSlot(callback) {
|
|
473
|
+
const limit = Math.max(1, this.config.maxConcurrentRequests);
|
|
474
|
+
if (this.activeRequests >= limit) {
|
|
475
|
+
await new Promise((resolve) => this.requestQueue.push(resolve));
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
this.activeRequests += 1;
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
return await callback();
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
const next = this.requestQueue.shift();
|
|
485
|
+
if (next) {
|
|
486
|
+
next();
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
this.activeRequests -= 1;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
async withEndpointRequestSlot(endpointGroup, limit, callback) {
|
|
494
|
+
const active = this.endpointActiveRequests.get(endpointGroup) ?? 0;
|
|
495
|
+
if (active >= limit) {
|
|
496
|
+
const queue = this.endpointRequestQueues.get(endpointGroup) ?? [];
|
|
497
|
+
await new Promise((resolve) => {
|
|
498
|
+
queue.push(resolve);
|
|
499
|
+
this.endpointRequestQueues.set(endpointGroup, queue);
|
|
216
500
|
});
|
|
217
501
|
}
|
|
502
|
+
else {
|
|
503
|
+
this.endpointActiveRequests.set(endpointGroup, active + 1);
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
return await callback();
|
|
507
|
+
}
|
|
218
508
|
finally {
|
|
219
|
-
|
|
509
|
+
const queue = this.endpointRequestQueues.get(endpointGroup);
|
|
510
|
+
const next = queue?.shift();
|
|
511
|
+
if (next) {
|
|
512
|
+
next();
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
this.endpointActiveRequests.set(endpointGroup, Math.max(0, (this.endpointActiveRequests.get(endpointGroup) ?? 1) - 1));
|
|
516
|
+
}
|
|
517
|
+
if (queue?.length === 0) {
|
|
518
|
+
this.endpointRequestQueues.delete(endpointGroup);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
recordEndpointMetric(path, durationMs, status, failed) {
|
|
523
|
+
const endpoint = metricEndpoint(path);
|
|
524
|
+
const metric = this.endpointMetrics.get(endpoint) ??
|
|
525
|
+
{
|
|
526
|
+
requests: 0,
|
|
527
|
+
errors: 0,
|
|
528
|
+
totalMs: 0,
|
|
529
|
+
maxMs: 0,
|
|
530
|
+
lastMs: 0,
|
|
531
|
+
statuses: new Map()
|
|
532
|
+
};
|
|
533
|
+
const statusKey = status === undefined ? "network_error" : String(status);
|
|
534
|
+
metric.requests += 1;
|
|
535
|
+
metric.errors += failed ? 1 : 0;
|
|
536
|
+
metric.totalMs += durationMs;
|
|
537
|
+
metric.maxMs = Math.max(metric.maxMs, durationMs);
|
|
538
|
+
metric.lastMs = durationMs;
|
|
539
|
+
metric.statuses.set(statusKey, (metric.statuses.get(statusKey) ?? 0) + 1);
|
|
540
|
+
this.endpointMetrics.set(endpoint, metric);
|
|
541
|
+
}
|
|
542
|
+
reserveUpstreamRequest(requestId, bucket) {
|
|
543
|
+
const now = Date.now();
|
|
544
|
+
const state = this.rateLimitState(bucket);
|
|
545
|
+
this.refreshUpstreamRateLimitWindow(state, now);
|
|
546
|
+
this.refreshUpstreamRequestWindow(now);
|
|
547
|
+
if ((this.globalBlockedUntilMs ?? 0) > now || (state.blockedUntilMs ?? 0) > now) {
|
|
548
|
+
throw localRateLimitError(requestId, bucket, state, this.globalBlockedUntilMs, this.upstreamRequestTimestampsMs.length);
|
|
549
|
+
}
|
|
550
|
+
if (this.shouldEnforceSharedRequestWindow() &&
|
|
551
|
+
this.upstreamRequestTimestampsMs.length >= UPSTREAM_KEY_IP_REQUEST_LIMIT) {
|
|
552
|
+
this.globalBlockedUntilMs =
|
|
553
|
+
(this.upstreamRequestTimestampsMs[0] ?? now) + UPSTREAM_KEY_IP_WINDOW_MS;
|
|
554
|
+
throw localRateLimitError(requestId, bucket, state, this.globalBlockedUntilMs, this.upstreamRequestTimestampsMs.length);
|
|
555
|
+
}
|
|
556
|
+
if (state.remaining !== undefined) {
|
|
557
|
+
if (state.remaining <= 0) {
|
|
558
|
+
state.blockedUntilMs =
|
|
559
|
+
state.resetAtMs && state.resetAtMs > now
|
|
560
|
+
? state.resetAtMs
|
|
561
|
+
: now + DEFAULT_RATE_LIMIT_COOLDOWN_MS;
|
|
562
|
+
throw localRateLimitError(requestId, bucket, state, this.globalBlockedUntilMs, this.upstreamRequestTimestampsMs.length);
|
|
563
|
+
}
|
|
564
|
+
state.remaining -= 1;
|
|
565
|
+
}
|
|
566
|
+
this.upstreamRequestTimestampsMs.push(now);
|
|
567
|
+
}
|
|
568
|
+
observeUpstreamRateLimit(bucket, headers, status) {
|
|
569
|
+
const now = Date.now();
|
|
570
|
+
const state = this.rateLimitState(bucket);
|
|
571
|
+
const limit = readRateLimitNumber(headers, ["x-ratelimit-limit", "ratelimit-limit"]);
|
|
572
|
+
const remaining = readRateLimitNumber(headers, ["x-ratelimit-remaining", "ratelimit-remaining"]);
|
|
573
|
+
const resetAtMs = parseRateLimitReset(headers, now);
|
|
574
|
+
const retryAtMs = parseRetryAfter(headers, now);
|
|
575
|
+
const previousResetAtMs = state.resetAtMs;
|
|
576
|
+
const sameWindow = resetAtMs === undefined ||
|
|
577
|
+
previousResetAtMs === undefined ||
|
|
578
|
+
Math.abs(resetAtMs - previousResetAtMs) < 1_000;
|
|
579
|
+
if (limit !== undefined) {
|
|
580
|
+
state.limit = limit;
|
|
581
|
+
}
|
|
582
|
+
if (remaining !== undefined) {
|
|
583
|
+
state.remaining =
|
|
584
|
+
sameWindow && state.remaining !== undefined
|
|
585
|
+
? Math.min(state.remaining, remaining)
|
|
586
|
+
: remaining;
|
|
587
|
+
}
|
|
588
|
+
if (resetAtMs !== undefined) {
|
|
589
|
+
state.resetAtMs = resetAtMs;
|
|
590
|
+
}
|
|
591
|
+
state.observedAtMs = now;
|
|
592
|
+
state.lastStatus = status;
|
|
593
|
+
if (status === 429 || ((status === 423 || status === 503) && retryAtMs !== undefined)) {
|
|
594
|
+
const advertisedBlockUntil = Math.max(retryAtMs ?? 0, resetAtMs && resetAtMs > now ? resetAtMs : 0);
|
|
595
|
+
const blockedUntil = advertisedBlockUntil > now ? advertisedBlockUntil : now + DEFAULT_RATE_LIMIT_COOLDOWN_MS;
|
|
596
|
+
if (status === 429) {
|
|
597
|
+
state.remaining = 0;
|
|
598
|
+
}
|
|
599
|
+
state.blockedUntilMs = blockedUntil;
|
|
600
|
+
this.globalBlockedUntilMs = Math.max(this.globalBlockedUntilMs ?? 0, blockedUntil);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (remaining === 0) {
|
|
604
|
+
state.blockedUntilMs =
|
|
605
|
+
resetAtMs && resetAtMs > now ? resetAtMs : now + DEFAULT_RATE_LIMIT_COOLDOWN_MS;
|
|
606
|
+
}
|
|
607
|
+
else if ((state.blockedUntilMs ?? 0) <= now) {
|
|
608
|
+
state.blockedUntilMs = undefined;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
observeUpstreamErrorBody(bucket, status, data) {
|
|
612
|
+
if (status !== 423) {
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const payload = asRecord(data);
|
|
616
|
+
const detail = asRecord(payload?.detail);
|
|
617
|
+
const rawLockedUntil = payload?.locked_until ?? detail?.locked_until;
|
|
618
|
+
if (typeof rawLockedUntil !== "string") {
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const parsed = Date.parse(rawLockedUntil);
|
|
622
|
+
if (!Number.isFinite(parsed) || parsed <= Date.now()) {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const blockedUntil = Math.min(parsed, Date.now() + MAX_UPSTREAM_RATE_LIMIT_DELAY_MS);
|
|
626
|
+
const state = this.rateLimitState(bucket);
|
|
627
|
+
state.blockedUntilMs = Math.max(state.blockedUntilMs ?? 0, blockedUntil);
|
|
628
|
+
this.globalBlockedUntilMs = Math.max(this.globalBlockedUntilMs ?? 0, blockedUntil);
|
|
629
|
+
}
|
|
630
|
+
rateLimitState(bucket) {
|
|
631
|
+
const state = this.upstreamRateLimits.get(bucket) ?? {};
|
|
632
|
+
this.upstreamRateLimits.set(bucket, state);
|
|
633
|
+
return state;
|
|
634
|
+
}
|
|
635
|
+
refreshUpstreamRequestWindow(now) {
|
|
636
|
+
const cutoff = now - UPSTREAM_KEY_IP_WINDOW_MS;
|
|
637
|
+
while (this.upstreamRequestTimestampsMs.length > 0 &&
|
|
638
|
+
(this.upstreamRequestTimestampsMs[0] ?? now) <= cutoff) {
|
|
639
|
+
this.upstreamRequestTimestampsMs.shift();
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
upstreamRequestWindowDiagnostics() {
|
|
643
|
+
const now = Date.now();
|
|
644
|
+
this.refreshUpstreamRequestWindow(now);
|
|
645
|
+
const enforced = this.shouldEnforceSharedRequestWindow();
|
|
646
|
+
return {
|
|
647
|
+
enforced,
|
|
648
|
+
limit: UPSTREAM_KEY_IP_REQUEST_LIMIT,
|
|
649
|
+
window_ms: UPSTREAM_KEY_IP_WINDOW_MS,
|
|
650
|
+
used: this.upstreamRequestTimestampsMs.length,
|
|
651
|
+
remaining: enforced
|
|
652
|
+
? Math.max(0, UPSTREAM_KEY_IP_REQUEST_LIMIT - this.upstreamRequestTimestampsMs.length)
|
|
653
|
+
: undefined,
|
|
654
|
+
resets_at: toIsoString(this.upstreamRequestTimestampsMs.length > 0
|
|
655
|
+
? (this.upstreamRequestTimestampsMs[0] ?? now) + UPSTREAM_KEY_IP_WINDOW_MS
|
|
656
|
+
: undefined)
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
shouldEnforceSharedRequestWindow() {
|
|
660
|
+
try {
|
|
661
|
+
const hostname = new URL(this.config.apiBaseUrl).hostname.toLowerCase();
|
|
662
|
+
if (hostname === "bbradar.io" || hostname.endsWith(".bbradar.io")) {
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
// Invalid base URLs fail when a request URL is constructed.
|
|
668
|
+
}
|
|
669
|
+
return [...this.upstreamRateLimits.values()].some((state) => state.limit !== undefined ||
|
|
670
|
+
state.remaining !== undefined ||
|
|
671
|
+
state.resetAtMs !== undefined);
|
|
672
|
+
}
|
|
673
|
+
refreshUpstreamRateLimitWindow(state, now) {
|
|
674
|
+
if (state.resetAtMs !== undefined && state.resetAtMs <= now) {
|
|
675
|
+
state.remaining = undefined;
|
|
676
|
+
state.resetAtMs = undefined;
|
|
677
|
+
}
|
|
678
|
+
if (state.blockedUntilMs !== undefined && state.blockedUntilMs <= now) {
|
|
679
|
+
state.blockedUntilMs = undefined;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
upstreamRateLimitDiagnostics() {
|
|
683
|
+
const now = Date.now();
|
|
684
|
+
const entries = [...this.upstreamRateLimits.entries()];
|
|
685
|
+
for (const [, state] of entries) {
|
|
686
|
+
this.refreshUpstreamRateLimitWindow(state, now);
|
|
687
|
+
}
|
|
688
|
+
if (this.globalBlockedUntilMs !== undefined && this.globalBlockedUntilMs <= now) {
|
|
689
|
+
this.globalBlockedUntilMs = undefined;
|
|
690
|
+
}
|
|
691
|
+
const observedEntries = entries.filter(([, state]) => state.observedAtMs !== undefined);
|
|
692
|
+
const latest = [...observedEntries].sort((left, right) => (right[1].observedAtMs ?? 0) - (left[1].observedAtMs ?? 0))[0];
|
|
693
|
+
const latestState = latest?.[1];
|
|
694
|
+
const globallyBlocked = (this.globalBlockedUntilMs ?? 0) > now;
|
|
695
|
+
const anyBucketBlocked = observedEntries.some(([, state]) => (state.blockedUntilMs ?? 0) > now);
|
|
696
|
+
const blockedUntil = Math.max(this.globalBlockedUntilMs ?? 0, ...observedEntries.map(([, state]) => state.blockedUntilMs ?? 0));
|
|
697
|
+
return {
|
|
698
|
+
observed: observedEntries.length > 0,
|
|
699
|
+
bucket: latest?.[0],
|
|
700
|
+
limit: latestState?.limit,
|
|
701
|
+
remaining: latestState?.remaining,
|
|
702
|
+
reset_at: toIsoString(latestState?.resetAtMs),
|
|
703
|
+
blocked: globallyBlocked || anyBucketBlocked,
|
|
704
|
+
globally_blocked: globallyBlocked,
|
|
705
|
+
blocked_until: toIsoString(blockedUntil > 0 ? blockedUntil : undefined),
|
|
706
|
+
observed_at: toIsoString(latestState?.observedAtMs),
|
|
707
|
+
last_status: latestState?.lastStatus,
|
|
708
|
+
buckets: Object.fromEntries(observedEntries.map(([bucket, state]) => [
|
|
709
|
+
bucket,
|
|
710
|
+
{
|
|
711
|
+
limit: state.limit,
|
|
712
|
+
remaining: state.remaining,
|
|
713
|
+
reset_at: toIsoString(state.resetAtMs),
|
|
714
|
+
blocked: (state.blockedUntilMs ?? 0) > now,
|
|
715
|
+
blocked_until: toIsoString(state.blockedUntilMs),
|
|
716
|
+
observed_at: toIsoString(state.observedAtMs),
|
|
717
|
+
last_status: state.lastStatus
|
|
718
|
+
}
|
|
719
|
+
]))
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
assertCircuitClosed(requestId, bucket) {
|
|
723
|
+
const state = this.circuitBreakers.get(bucket);
|
|
724
|
+
const now = Date.now();
|
|
725
|
+
if (!state?.openUntilMs || state.openUntilMs <= now) {
|
|
726
|
+
if (state?.openUntilMs !== undefined) {
|
|
727
|
+
state.openUntilMs = undefined;
|
|
728
|
+
}
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
throw new BBRadarApiError({
|
|
732
|
+
message: `BBRadar ${bucket} circuit is temporarily open; no upstream request was sent.`,
|
|
733
|
+
requestId,
|
|
734
|
+
status: 503,
|
|
735
|
+
detail: {
|
|
736
|
+
source: "circuit_breaker",
|
|
737
|
+
bucket,
|
|
738
|
+
consecutive_failures: state.consecutiveFailures,
|
|
739
|
+
retry_at: new Date(state.openUntilMs).toISOString()
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
observeCircuitResponse(bucket, status) {
|
|
744
|
+
if (status >= 200 && status < 400) {
|
|
745
|
+
const state = this.circuitBreakers.get(bucket);
|
|
746
|
+
if (state) {
|
|
747
|
+
state.consecutiveFailures = 0;
|
|
748
|
+
state.openUntilMs = undefined;
|
|
749
|
+
state.lastSuccessAtMs = Date.now();
|
|
750
|
+
state.lastStatus = status;
|
|
751
|
+
}
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (status === 423 || status === 429 || status >= 500) {
|
|
755
|
+
this.observeCircuitFailure(bucket, status);
|
|
220
756
|
}
|
|
221
757
|
}
|
|
758
|
+
observeCircuitFailure(bucket, status) {
|
|
759
|
+
const state = this.circuitBreakers.get(bucket) ?? { consecutiveFailures: 0 };
|
|
760
|
+
state.consecutiveFailures += 1;
|
|
761
|
+
state.lastFailureAtMs = Date.now();
|
|
762
|
+
state.lastStatus = status;
|
|
763
|
+
if (state.consecutiveFailures >=
|
|
764
|
+
(this.config.circuitBreakerFailureThreshold ?? DEFAULT_CIRCUIT_FAILURE_THRESHOLD)) {
|
|
765
|
+
state.openUntilMs =
|
|
766
|
+
Date.now() +
|
|
767
|
+
(this.config.circuitBreakerCooldownMs ?? DEFAULT_CIRCUIT_COOLDOWN_MS);
|
|
768
|
+
}
|
|
769
|
+
this.circuitBreakers.set(bucket, state);
|
|
770
|
+
}
|
|
771
|
+
circuitBreakerDiagnostics() {
|
|
772
|
+
const now = Date.now();
|
|
773
|
+
return {
|
|
774
|
+
failure_threshold: this.config.circuitBreakerFailureThreshold ?? DEFAULT_CIRCUIT_FAILURE_THRESHOLD,
|
|
775
|
+
cooldown_ms: this.config.circuitBreakerCooldownMs ?? DEFAULT_CIRCUIT_COOLDOWN_MS,
|
|
776
|
+
by_endpoint: Object.fromEntries([...this.circuitBreakers.entries()].map(([bucket, state]) => [
|
|
777
|
+
bucket,
|
|
778
|
+
{
|
|
779
|
+
state: (state.openUntilMs ?? 0) > now ? "open" : state.consecutiveFailures > 0 ? "closed_degraded" : "closed",
|
|
780
|
+
consecutive_failures: state.consecutiveFailures,
|
|
781
|
+
open_until: toIsoString(state.openUntilMs),
|
|
782
|
+
last_failure_at: toIsoString(state.lastFailureAtMs),
|
|
783
|
+
last_success_at: toIsoString(state.lastSuccessAtMs),
|
|
784
|
+
last_status: state.lastStatus
|
|
785
|
+
}
|
|
786
|
+
]))
|
|
787
|
+
};
|
|
788
|
+
}
|
|
222
789
|
buildUrl(path, query) {
|
|
223
790
|
const url = new URL(`${this.config.apiBaseUrl}${path}`);
|
|
224
791
|
for (const [key, value] of Object.entries(query ?? {})) {
|
|
@@ -227,20 +794,19 @@ export class BBRadarClient {
|
|
|
227
794
|
}
|
|
228
795
|
if (Array.isArray(value)) {
|
|
229
796
|
if (value.length > 0) {
|
|
230
|
-
url.searchParams.set(key, value.join(","));
|
|
797
|
+
url.searchParams.set(key, [...value].map(String).sort().join(","));
|
|
231
798
|
}
|
|
232
799
|
continue;
|
|
233
800
|
}
|
|
234
801
|
url.searchParams.set(key, String(value));
|
|
235
802
|
}
|
|
803
|
+
url.searchParams.sort();
|
|
236
804
|
return url;
|
|
237
805
|
}
|
|
238
806
|
headers(body) {
|
|
239
807
|
const headers = {
|
|
240
808
|
authorization: `Bearer ${this.config.apiKey}`,
|
|
241
809
|
accept: "application/json",
|
|
242
|
-
"cache-control": "no-cache, no-store, max-age=0",
|
|
243
|
-
pragma: "no-cache",
|
|
244
810
|
"user-agent": "@bbradar/mcp"
|
|
245
811
|
};
|
|
246
812
|
if (body !== undefined) {
|
|
@@ -249,12 +815,155 @@ export class BBRadarClient {
|
|
|
249
815
|
return headers;
|
|
250
816
|
}
|
|
251
817
|
}
|
|
818
|
+
function localRateLimitError(requestId, bucket, state, globalBlockedUntilMs, upstreamWindowUsed) {
|
|
819
|
+
return new BBRadarApiError({
|
|
820
|
+
message: "BBRadar API quota is temporarily exhausted; no upstream request was sent.",
|
|
821
|
+
requestId,
|
|
822
|
+
status: 429,
|
|
823
|
+
detail: {
|
|
824
|
+
source: "local_rate_limit_guard",
|
|
825
|
+
bucket,
|
|
826
|
+
limit: state.limit,
|
|
827
|
+
remaining: state.remaining,
|
|
828
|
+
reset_at: toIsoString(state.resetAtMs),
|
|
829
|
+
retry_at: toIsoString(Math.max(state.blockedUntilMs ?? 0, globalBlockedUntilMs ?? 0)),
|
|
830
|
+
shared_key_ip_window: {
|
|
831
|
+
limit: UPSTREAM_KEY_IP_REQUEST_LIMIT,
|
|
832
|
+
used: upstreamWindowUsed,
|
|
833
|
+
remaining: Math.max(0, UPSTREAM_KEY_IP_REQUEST_LIMIT - upstreamWindowUsed)
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
function readRateLimitNumber(headers, names) {
|
|
839
|
+
for (const name of names) {
|
|
840
|
+
const value = headers.get(name);
|
|
841
|
+
if (value === null) {
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
const parsed = Number(value.trim());
|
|
845
|
+
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
846
|
+
return Math.floor(parsed);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return undefined;
|
|
850
|
+
}
|
|
851
|
+
function parseRateLimitReset(headers, now) {
|
|
852
|
+
const value = headers.get("x-ratelimit-reset") ?? headers.get("ratelimit-reset");
|
|
853
|
+
if (!value) {
|
|
854
|
+
return undefined;
|
|
855
|
+
}
|
|
856
|
+
const numeric = Number(value.trim());
|
|
857
|
+
if (Number.isFinite(numeric) && numeric >= 0) {
|
|
858
|
+
if (numeric >= 1_000_000_000_000) {
|
|
859
|
+
return boundUpstreamRateLimitTimestamp(numeric, now);
|
|
860
|
+
}
|
|
861
|
+
if (numeric >= 1_000_000_000) {
|
|
862
|
+
return boundUpstreamRateLimitTimestamp(numeric * 1_000, now);
|
|
863
|
+
}
|
|
864
|
+
return boundUpstreamRateLimitTimestamp(now + numeric * 1_000, now);
|
|
865
|
+
}
|
|
866
|
+
const timestamp = Date.parse(value);
|
|
867
|
+
return boundUpstreamRateLimitTimestamp(timestamp, now);
|
|
868
|
+
}
|
|
869
|
+
function parseRetryAfter(headers, now) {
|
|
870
|
+
const value = headers.get("retry-after");
|
|
871
|
+
if (!value) {
|
|
872
|
+
return undefined;
|
|
873
|
+
}
|
|
874
|
+
const seconds = Number(value.trim());
|
|
875
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
876
|
+
return boundUpstreamRateLimitTimestamp(now + seconds * 1_000, now);
|
|
877
|
+
}
|
|
878
|
+
const timestamp = Date.parse(value);
|
|
879
|
+
return boundUpstreamRateLimitTimestamp(timestamp, now);
|
|
880
|
+
}
|
|
881
|
+
function boundUpstreamRateLimitTimestamp(timestamp, now) {
|
|
882
|
+
if (!Number.isFinite(timestamp)) {
|
|
883
|
+
return undefined;
|
|
884
|
+
}
|
|
885
|
+
return Math.min(timestamp, now + MAX_UPSTREAM_RATE_LIMIT_DELAY_MS);
|
|
886
|
+
}
|
|
887
|
+
function toIsoString(timestamp) {
|
|
888
|
+
if (timestamp === undefined || !Number.isFinite(timestamp)) {
|
|
889
|
+
return undefined;
|
|
890
|
+
}
|
|
891
|
+
const date = new Date(timestamp);
|
|
892
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
893
|
+
}
|
|
894
|
+
function metricEndpoint(path) {
|
|
895
|
+
if (/^\/pro\/programs\/.+\/targets$/.test(path)) {
|
|
896
|
+
return "/pro/programs/:program_id/targets";
|
|
897
|
+
}
|
|
898
|
+
if (/^\/pro\/programs\/.+/.test(path)) {
|
|
899
|
+
return "/pro/programs/:program_id";
|
|
900
|
+
}
|
|
901
|
+
if (/^\/pro\/opportunities\/[^/]+$/.test(path)) {
|
|
902
|
+
return "/pro/opportunities/:level";
|
|
903
|
+
}
|
|
904
|
+
return path;
|
|
905
|
+
}
|
|
906
|
+
function rateLimitEndpointGroup(path) {
|
|
907
|
+
if (path === "/pro/targets/changes") {
|
|
908
|
+
return "change_feeds";
|
|
909
|
+
}
|
|
910
|
+
if (path === "/pro/repositories/changes") {
|
|
911
|
+
return "change_feeds";
|
|
912
|
+
}
|
|
913
|
+
if (/^\/pro\/programs\/.+\/targets$/.test(path)) {
|
|
914
|
+
return "target_operations";
|
|
915
|
+
}
|
|
916
|
+
if (path === "/pro/capabilities" ||
|
|
917
|
+
path === "/pro/programs" ||
|
|
918
|
+
/^\/pro\/programs\/.+/.test(path) ||
|
|
919
|
+
/^\/pro\/opportunities\/[^/]+$/.test(path)) {
|
|
920
|
+
return "programs_and_opportunities";
|
|
921
|
+
}
|
|
922
|
+
if (path === "/pro/targets/search") {
|
|
923
|
+
return "target_operations";
|
|
924
|
+
}
|
|
925
|
+
if (path === "/pro/targets/export") {
|
|
926
|
+
return "target_operations";
|
|
927
|
+
}
|
|
928
|
+
return "other";
|
|
929
|
+
}
|
|
930
|
+
function concurrencyEndpointGroup(path) {
|
|
931
|
+
if (path === "/pro/targets/changes" || path === "/pro/repositories/changes") {
|
|
932
|
+
return "change_feeds";
|
|
933
|
+
}
|
|
934
|
+
if (path === "/pro/targets/export") {
|
|
935
|
+
return "target_export";
|
|
936
|
+
}
|
|
937
|
+
return undefined;
|
|
938
|
+
}
|
|
939
|
+
function endpointConcurrencyLimit(endpointGroup) {
|
|
940
|
+
if (endpointGroup === "change_feeds") {
|
|
941
|
+
return CHANGE_FEED_CONCURRENCY_LIMIT;
|
|
942
|
+
}
|
|
943
|
+
if (endpointGroup === "target_export") {
|
|
944
|
+
return TARGET_EXPORT_CONCURRENCY_LIMIT;
|
|
945
|
+
}
|
|
946
|
+
return undefined;
|
|
947
|
+
}
|
|
948
|
+
function isNegativeCacheStatus(status) {
|
|
949
|
+
return status === 404 || status === 405;
|
|
950
|
+
}
|
|
951
|
+
function asRecord(value) {
|
|
952
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
953
|
+
? value
|
|
954
|
+
: undefined;
|
|
955
|
+
}
|
|
252
956
|
async function parseResponseBody(response) {
|
|
253
957
|
if (response.status === 204) {
|
|
254
958
|
return null;
|
|
255
959
|
}
|
|
256
960
|
const contentType = response.headers.get("content-type") ?? "";
|
|
257
|
-
const
|
|
961
|
+
const advertisedLength = Number(response.headers.get("content-length"));
|
|
962
|
+
if (Number.isFinite(advertisedLength) && advertisedLength > MAX_RESPONSE_BODY_BYTES) {
|
|
963
|
+
await response.body?.cancel();
|
|
964
|
+
throw new Error("BBRadar API response exceeded the MCP response-size limit.");
|
|
965
|
+
}
|
|
966
|
+
const text = await readBoundedResponseText(response, MAX_RESPONSE_BODY_BYTES);
|
|
258
967
|
if (text.length === 0) {
|
|
259
968
|
return null;
|
|
260
969
|
}
|
|
@@ -268,6 +977,34 @@ async function parseResponseBody(response) {
|
|
|
268
977
|
}
|
|
269
978
|
return text;
|
|
270
979
|
}
|
|
980
|
+
async function readBoundedResponseText(response, maxBytes) {
|
|
981
|
+
if (!response.body) {
|
|
982
|
+
return "";
|
|
983
|
+
}
|
|
984
|
+
const reader = response.body.getReader();
|
|
985
|
+
const decoder = new TextDecoder();
|
|
986
|
+
let totalBytes = 0;
|
|
987
|
+
let text = "";
|
|
988
|
+
try {
|
|
989
|
+
while (true) {
|
|
990
|
+
const { done, value } = await reader.read();
|
|
991
|
+
if (done) {
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
totalBytes += value.byteLength;
|
|
995
|
+
if (totalBytes > maxBytes) {
|
|
996
|
+
await reader.cancel();
|
|
997
|
+
throw new Error("BBRadar API response exceeded the MCP response-size limit.");
|
|
998
|
+
}
|
|
999
|
+
text += decoder.decode(value, { stream: true });
|
|
1000
|
+
}
|
|
1001
|
+
text += decoder.decode();
|
|
1002
|
+
return text;
|
|
1003
|
+
}
|
|
1004
|
+
finally {
|
|
1005
|
+
reader.releaseLock();
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
271
1008
|
function extractRequestId(headers) {
|
|
272
1009
|
return (headers.get("x-request-id") ??
|
|
273
1010
|
headers.get("x-correlation-id") ??
|
|
@@ -290,7 +1027,7 @@ function formatApiErrorMessage(status, data) {
|
|
|
290
1027
|
: "Request was rejected.";
|
|
291
1028
|
return `BBRadar API returned ${status}: ${suffix}`;
|
|
292
1029
|
}
|
|
293
|
-
function cloneApiError(error, requestId) {
|
|
1030
|
+
function cloneApiError(error, requestId, coalesced = error instanceof BBRadarApiError && error.coalesced) {
|
|
294
1031
|
if (!(error instanceof BBRadarApiError)) {
|
|
295
1032
|
return error;
|
|
296
1033
|
}
|
|
@@ -301,6 +1038,9 @@ function cloneApiError(error, requestId) {
|
|
|
301
1038
|
status: error.status,
|
|
302
1039
|
detail: error.detail,
|
|
303
1040
|
errors: error.errors,
|
|
1041
|
+
cached: error.cached,
|
|
1042
|
+
coalesced,
|
|
1043
|
+
cacheExpiresAt: error.cacheExpiresAt,
|
|
304
1044
|
cause: error
|
|
305
1045
|
});
|
|
306
1046
|
}
|