@bbradar/mcp 0.2.3 → 0.2.6
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/CHANGELOG.md +13 -0
- package/README.md +74 -10
- package/benchmark/compare-revisions.mjs +164 -0
- package/benchmark/pro-benchmark.mjs +84 -0
- package/dist/bbradarClient.d.ts +22 -4
- package/dist/bbradarClient.js +253 -48
- package/dist/bbradarClient.js.map +1 -1
- package/dist/sanitize.d.ts +2 -0
- package/dist/sanitize.js +59 -12
- package/dist/sanitize.js.map +1 -1
- package/dist/server.js +619 -88
- package/dist/server.js.map +1 -1
- package/package.json +5 -3
package/dist/bbradarClient.js
CHANGED
|
@@ -1,7 +1,75 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
3
|
import { TtlCache } from "./cache.js";
|
|
3
4
|
import { stableStringify } from "./json.js";
|
|
4
5
|
import { encodeProgramIdPath } from "./url.js";
|
|
6
|
+
function publicPayload(payload) {
|
|
7
|
+
const { cacheAllowed: _cacheAllowed, ...publicFields } = payload;
|
|
8
|
+
return publicFields;
|
|
9
|
+
}
|
|
10
|
+
function subscribeToRequest(promise, requestId, signal, shared) {
|
|
11
|
+
if (shared)
|
|
12
|
+
shared.consumers += 1;
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
let settled = false;
|
|
15
|
+
const finish = (callback) => {
|
|
16
|
+
if (settled)
|
|
17
|
+
return;
|
|
18
|
+
settled = true;
|
|
19
|
+
signal?.removeEventListener("abort", cancel);
|
|
20
|
+
if (shared)
|
|
21
|
+
shared.consumers -= 1;
|
|
22
|
+
callback();
|
|
23
|
+
};
|
|
24
|
+
const cancel = () => finish(() => {
|
|
25
|
+
if (shared?.consumers === 0)
|
|
26
|
+
shared.controller.abort(signal?.reason);
|
|
27
|
+
reject(new BBRadarApiError({ message: "BBRadar API request was cancelled or exceeded its tool deadline.", requestId, status: 408 }));
|
|
28
|
+
});
|
|
29
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
30
|
+
promise.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
|
|
31
|
+
if (signal?.aborted)
|
|
32
|
+
cancel();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function waitForRequestSlot(queue, deadline, requestId, signal) {
|
|
36
|
+
if (queue.length >= MAX_QUEUED_REQUESTS) {
|
|
37
|
+
throw new BBRadarApiError({ message: "BBRadar API request queue is full.", requestId, status: 503 });
|
|
38
|
+
}
|
|
39
|
+
if (deadline <= Date.now() || signal?.aborted) {
|
|
40
|
+
throw new BBRadarApiError({ message: "BBRadar API request timed out or was cancelled while queued.", requestId, status: 408 });
|
|
41
|
+
}
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
let settled = false;
|
|
44
|
+
const remove = () => {
|
|
45
|
+
const index = queue.indexOf(grant);
|
|
46
|
+
if (index >= 0)
|
|
47
|
+
queue.splice(index, 1);
|
|
48
|
+
};
|
|
49
|
+
const cleanup = () => {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
signal?.removeEventListener("abort", cancel);
|
|
52
|
+
};
|
|
53
|
+
const grant = () => {
|
|
54
|
+
if (settled)
|
|
55
|
+
return;
|
|
56
|
+
settled = true;
|
|
57
|
+
cleanup();
|
|
58
|
+
resolve();
|
|
59
|
+
};
|
|
60
|
+
const cancel = () => {
|
|
61
|
+
if (settled)
|
|
62
|
+
return;
|
|
63
|
+
settled = true;
|
|
64
|
+
remove();
|
|
65
|
+
cleanup();
|
|
66
|
+
reject(new BBRadarApiError({ message: "BBRadar API request timed out or was cancelled while queued.", requestId, status: 408 }));
|
|
67
|
+
};
|
|
68
|
+
const timer = setTimeout(cancel, Math.max(1, deadline - Date.now()));
|
|
69
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
70
|
+
queue.push(grant);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
5
73
|
const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60_000;
|
|
6
74
|
const MAX_UPSTREAM_RATE_LIMIT_DELAY_MS = 10 * 60_000;
|
|
7
75
|
const CHANGE_FEED_CONCURRENCY_LIMIT = 2;
|
|
@@ -13,6 +81,8 @@ const MAX_REQUEST_BODY_BYTES = 64 * 1024;
|
|
|
13
81
|
const DEFAULT_PROGRAM_SWR_MS = 5 * 60_000;
|
|
14
82
|
const DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 3;
|
|
15
83
|
const DEFAULT_CIRCUIT_COOLDOWN_MS = 30_000;
|
|
84
|
+
const SNAPSHOT_START_MIN_TIMEOUT_MS = 60_000;
|
|
85
|
+
const MAX_QUEUED_REQUESTS = 100;
|
|
16
86
|
export class BBRadarApiError extends Error {
|
|
17
87
|
requestId;
|
|
18
88
|
upstreamRequestId;
|
|
@@ -22,6 +92,7 @@ export class BBRadarApiError extends Error {
|
|
|
22
92
|
cached;
|
|
23
93
|
coalesced;
|
|
24
94
|
cacheExpiresAt;
|
|
95
|
+
cacheAllowed;
|
|
25
96
|
constructor(params) {
|
|
26
97
|
super(params.message, { cause: params.cause });
|
|
27
98
|
this.name = "BBRadarApiError";
|
|
@@ -33,6 +104,7 @@ export class BBRadarApiError extends Error {
|
|
|
33
104
|
this.cached = params.cached ?? false;
|
|
34
105
|
this.coalesced = params.coalesced ?? false;
|
|
35
106
|
this.cacheExpiresAt = params.cacheExpiresAt;
|
|
107
|
+
this.cacheAllowed = params.cacheAllowed ?? true;
|
|
36
108
|
}
|
|
37
109
|
}
|
|
38
110
|
export class BBRadarClient {
|
|
@@ -40,6 +112,8 @@ export class BBRadarClient {
|
|
|
40
112
|
cache;
|
|
41
113
|
negativeCache;
|
|
42
114
|
inFlight = new Map();
|
|
115
|
+
sharedRequests = new Map();
|
|
116
|
+
requestContext = new AsyncLocalStorage();
|
|
43
117
|
endpointMetrics = new Map();
|
|
44
118
|
requestQueue = [];
|
|
45
119
|
endpointRequestQueues = new Map();
|
|
@@ -56,6 +130,24 @@ export class BBRadarClient {
|
|
|
56
130
|
this.cache = new TtlCache(config.cacheMaxEntries, config.cacheTtlMs, config.programStaleWhileRevalidateMs ?? DEFAULT_PROGRAM_SWR_MS);
|
|
57
131
|
this.negativeCache = new TtlCache(config.cacheMaxEntries, config.negativeCacheTtlMs);
|
|
58
132
|
}
|
|
133
|
+
async withRequestContext(signal, callback, snapshotStart = false) {
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
const onAbort = () => controller.abort(signal.reason);
|
|
136
|
+
const timeout = setTimeout(() => controller.abort(new DOMException("Tool deadline exceeded.", "TimeoutError")), snapshotStart
|
|
137
|
+
? Math.max(this.config.requestTimeoutMs, SNAPSHOT_START_MIN_TIMEOUT_MS)
|
|
138
|
+
: this.config.requestTimeoutMs);
|
|
139
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
140
|
+
if (signal.aborted)
|
|
141
|
+
onAbort();
|
|
142
|
+
try {
|
|
143
|
+
return await this.requestContext.run(controller.signal, callback);
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
clearTimeout(timeout);
|
|
147
|
+
signal.removeEventListener("abort", onAbort);
|
|
148
|
+
controller.abort();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
59
151
|
diagnostics() {
|
|
60
152
|
const endpointEntries = [...this.endpointMetrics.entries()];
|
|
61
153
|
const totalRequests = endpointEntries.reduce((total, [, metric]) => total + metric.requests, 0);
|
|
@@ -176,6 +268,16 @@ export class BBRadarClient {
|
|
|
176
268
|
? [...value]
|
|
177
269
|
: [...fallback];
|
|
178
270
|
}
|
|
271
|
+
supportsStableSnapshot(endpoint) {
|
|
272
|
+
if (this.capabilityState.status !== "negotiated") {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
const contract = asRecord(this.capabilityState.contract);
|
|
276
|
+
const behavior = asRecord(contract?.behavior);
|
|
277
|
+
const stableSnapshots = asRecord(behavior?.stable_snapshots);
|
|
278
|
+
const endpoints = stableSnapshots?.endpoints;
|
|
279
|
+
return Array.isArray(endpoints) && endpoints.includes(endpoint);
|
|
280
|
+
}
|
|
179
281
|
async fetchCapabilityContract() {
|
|
180
282
|
try {
|
|
181
283
|
const result = await this.getCapabilities({ cache: false, coalesce: false });
|
|
@@ -201,10 +303,11 @@ export class BBRadarClient {
|
|
|
201
303
|
throw error;
|
|
202
304
|
}
|
|
203
305
|
}
|
|
204
|
-
listPrograms(query) {
|
|
306
|
+
listPrograms(query, controls = {}) {
|
|
205
307
|
return this.request({
|
|
206
308
|
path: "/pro/programs",
|
|
207
|
-
query
|
|
309
|
+
query,
|
|
310
|
+
...snapshotAwareReadControls(query, controls, true, this.config.requestTimeoutMs)
|
|
208
311
|
});
|
|
209
312
|
}
|
|
210
313
|
getCapabilities(options = {}) {
|
|
@@ -229,24 +332,25 @@ export class BBRadarClient {
|
|
|
229
332
|
}
|
|
230
333
|
});
|
|
231
334
|
}
|
|
232
|
-
getRecentChanges(query) {
|
|
335
|
+
getRecentChanges(query, controls = {}) {
|
|
233
336
|
return this.request({
|
|
234
337
|
path: "/pro/targets/changes",
|
|
235
338
|
query,
|
|
236
|
-
|
|
339
|
+
...snapshotAwareReadControls(query, controls, query.after_id === undefined, this.config.requestTimeoutMs)
|
|
237
340
|
});
|
|
238
341
|
}
|
|
239
|
-
getRepositoryChanges(query) {
|
|
342
|
+
getRepositoryChanges(query, controls = {}) {
|
|
240
343
|
return this.request({
|
|
241
344
|
path: "/pro/repositories/changes",
|
|
242
345
|
query,
|
|
243
|
-
|
|
346
|
+
...snapshotAwareReadControls(query, controls, query.after_id === undefined, this.config.requestTimeoutMs)
|
|
244
347
|
});
|
|
245
348
|
}
|
|
246
|
-
searchTargets(query) {
|
|
349
|
+
searchTargets(query, controls = {}) {
|
|
247
350
|
return this.request({
|
|
248
351
|
path: "/pro/targets/search",
|
|
249
|
-
query
|
|
352
|
+
query,
|
|
353
|
+
...snapshotAwareReadControls(query, controls, true, this.config.requestTimeoutMs)
|
|
250
354
|
});
|
|
251
355
|
}
|
|
252
356
|
getOpportunities(level, query) {
|
|
@@ -255,6 +359,27 @@ export class BBRadarClient {
|
|
|
255
359
|
query
|
|
256
360
|
});
|
|
257
361
|
}
|
|
362
|
+
getHistoryChanges(query) {
|
|
363
|
+
return this.request({ path: "/pro/history/changes", query, cache: false });
|
|
364
|
+
}
|
|
365
|
+
getHistoryChangeDetail(changeId, query) {
|
|
366
|
+
return this.request({ path: `/pro/history/changes/${encodeURIComponent(changeId)}`, query, cache: false });
|
|
367
|
+
}
|
|
368
|
+
getProgramHistory(programId, query) {
|
|
369
|
+
return this.request({ path: `/pro/history/programs/${encodeProgramIdPath(programId)}`, query, cache: false });
|
|
370
|
+
}
|
|
371
|
+
getProgramActivity(programId) {
|
|
372
|
+
return this.request({ path: `/pro/activity/programs/${encodeProgramIdPath(programId)}`, cache: false });
|
|
373
|
+
}
|
|
374
|
+
getAnalyticsPlatformSeries(platform, query) {
|
|
375
|
+
return this.request({ path: `/pro/analytics/platforms/${encodeURIComponent(platform)}/series`, query, cache: false });
|
|
376
|
+
}
|
|
377
|
+
getAnalyticsProgramActivity(query) {
|
|
378
|
+
return this.request({ path: "/pro/analytics/programs/activity", query, cache: false });
|
|
379
|
+
}
|
|
380
|
+
getAnalyticsProgramDetail(programId, query) {
|
|
381
|
+
return this.request({ path: `/pro/analytics/programs/${encodeProgramIdPath(programId)}`, query, cache: false });
|
|
382
|
+
}
|
|
258
383
|
exportTargets(body) {
|
|
259
384
|
return this.request({
|
|
260
385
|
method: "POST",
|
|
@@ -265,8 +390,33 @@ export class BBRadarClient {
|
|
|
265
390
|
});
|
|
266
391
|
}
|
|
267
392
|
async request(options) {
|
|
393
|
+
const contextSignal = this.requestContext.getStore();
|
|
394
|
+
if (!options.signal || !contextSignal || options.signal === contextSignal) {
|
|
395
|
+
return this.requestWithSignal(options, options.signal ?? contextSignal);
|
|
396
|
+
}
|
|
397
|
+
// AbortSignal.any is unavailable on the earliest supported Node 20 releases.
|
|
398
|
+
// Remove both listeners after this caller settles, even for coalesced reads.
|
|
399
|
+
const controller = new AbortController();
|
|
400
|
+
const signals = [options.signal, contextSignal];
|
|
401
|
+
const listeners = signals.map((signal) => {
|
|
402
|
+
const abort = () => controller.abort(signal.reason);
|
|
403
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
404
|
+
if (signal.aborted)
|
|
405
|
+
abort();
|
|
406
|
+
return abort;
|
|
407
|
+
});
|
|
408
|
+
try {
|
|
409
|
+
return await this.requestWithSignal(options, controller.signal);
|
|
410
|
+
}
|
|
411
|
+
finally {
|
|
412
|
+
signals.forEach((signal, index) => signal.removeEventListener("abort", listeners[index]));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
async requestWithSignal(options, signal) {
|
|
268
416
|
const method = options.method ?? "GET";
|
|
269
417
|
const requestId = randomUUID();
|
|
418
|
+
if (signal?.aborted)
|
|
419
|
+
throw new BBRadarApiError({ message: "BBRadar API request was cancelled or exceeded its tool deadline.", requestId, status: 408 });
|
|
270
420
|
const url = this.buildUrl(options.path, options.query);
|
|
271
421
|
const requestKey = stableStringify({
|
|
272
422
|
method,
|
|
@@ -286,7 +436,7 @@ export class BBRadarClient {
|
|
|
286
436
|
this.refreshInBackground(options, method, url, requestKey);
|
|
287
437
|
}
|
|
288
438
|
return {
|
|
289
|
-
...cached.value,
|
|
439
|
+
...publicPayload(cached.value),
|
|
290
440
|
requestId,
|
|
291
441
|
cached: true,
|
|
292
442
|
stale: cached.stale,
|
|
@@ -307,11 +457,11 @@ export class BBRadarClient {
|
|
|
307
457
|
}
|
|
308
458
|
if (coalesceable) {
|
|
309
459
|
const pending = this.inFlight.get(requestKey);
|
|
310
|
-
if (pending) {
|
|
460
|
+
if (pending && !this.sharedRequests.get(requestKey)?.controller.signal.aborted) {
|
|
311
461
|
try {
|
|
312
|
-
const result = (await pending);
|
|
462
|
+
const result = (await subscribeToRequest(pending, requestId, signal, this.sharedRequests.get(requestKey)));
|
|
313
463
|
return {
|
|
314
|
-
...result,
|
|
464
|
+
...publicPayload(result),
|
|
315
465
|
requestId,
|
|
316
466
|
cached: false,
|
|
317
467
|
coalesced: true
|
|
@@ -322,27 +472,20 @@ export class BBRadarClient {
|
|
|
322
472
|
}
|
|
323
473
|
}
|
|
324
474
|
}
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
try {
|
|
330
|
-
const result = await request;
|
|
331
|
-
if (cacheable) {
|
|
475
|
+
const shared = coalesceable ? { controller: new AbortController(), consumers: 0 } : undefined;
|
|
476
|
+
const requestOptions = { ...options, ...(shared ? { signal: shared.controller.signal } : signal ? { signal } : {}) };
|
|
477
|
+
const request = this.fetchFromApi(requestOptions, method, url, requestId).then((result) => {
|
|
478
|
+
if (cacheable && result.cacheAllowed !== false) {
|
|
332
479
|
this.cache.set(requestKey, result);
|
|
333
480
|
}
|
|
334
481
|
if (negativeCacheable) {
|
|
335
482
|
this.negativeCache.delete(requestKey);
|
|
336
483
|
}
|
|
337
|
-
return
|
|
338
|
-
|
|
339
|
-
requestId,
|
|
340
|
-
cached: false
|
|
341
|
-
};
|
|
342
|
-
}
|
|
343
|
-
catch (error) {
|
|
484
|
+
return result;
|
|
485
|
+
}).catch((error) => {
|
|
344
486
|
if (negativeCacheable &&
|
|
345
487
|
error instanceof BBRadarApiError &&
|
|
488
|
+
error.cacheAllowed &&
|
|
346
489
|
error.status !== undefined &&
|
|
347
490
|
isNegativeCacheStatus(error.status)) {
|
|
348
491
|
this.negativeCache.set(requestKey, {
|
|
@@ -354,14 +497,21 @@ export class BBRadarClient {
|
|
|
354
497
|
});
|
|
355
498
|
}
|
|
356
499
|
throw error;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
if (coalesceable) {
|
|
500
|
+
}).finally(() => {
|
|
501
|
+
if (coalesceable && this.inFlight.get(requestKey) === request) {
|
|
360
502
|
this.inFlight.delete(requestKey);
|
|
503
|
+
this.sharedRequests.delete(requestKey);
|
|
361
504
|
}
|
|
505
|
+
});
|
|
506
|
+
if (coalesceable) {
|
|
507
|
+
this.inFlight.set(requestKey, request);
|
|
508
|
+
this.sharedRequests.set(requestKey, shared);
|
|
362
509
|
}
|
|
510
|
+
const result = await subscribeToRequest(request, requestId, signal, shared);
|
|
511
|
+
return { ...publicPayload(result), requestId, cached: false };
|
|
363
512
|
}
|
|
364
513
|
async fetchFromApi(options, method, url, requestId) {
|
|
514
|
+
const deadline = Date.now() + (options.timeoutMs ?? this.config.requestTimeoutMs);
|
|
365
515
|
const serializedBody = options.body === undefined ? undefined : JSON.stringify(options.body);
|
|
366
516
|
if (serializedBody !== undefined) {
|
|
367
517
|
const bodyBytes = Buffer.byteLength(serializedBody, "utf8");
|
|
@@ -376,11 +526,20 @@ export class BBRadarClient {
|
|
|
376
526
|
}
|
|
377
527
|
return this.withRequestSlot(options.path, async () => {
|
|
378
528
|
const rateLimitBucket = rateLimitEndpointGroup(options.path);
|
|
529
|
+
if (options.signal?.aborted)
|
|
530
|
+
throw new BBRadarApiError({ message: "BBRadar API request was cancelled.", requestId, status: 408 });
|
|
379
531
|
this.assertCircuitClosed(requestId, rateLimitBucket);
|
|
380
532
|
this.reserveUpstreamRequest(requestId, rateLimitBucket);
|
|
381
533
|
const startedAt = Date.now();
|
|
382
534
|
const controller = new AbortController();
|
|
383
|
-
const
|
|
535
|
+
const remainingMs = deadline - Date.now();
|
|
536
|
+
if (remainingMs <= 0)
|
|
537
|
+
throw new BBRadarApiError({ message: "BBRadar API request timed out while queued.", requestId, status: 408 });
|
|
538
|
+
const timeout = setTimeout(() => controller.abort(), remainingMs);
|
|
539
|
+
const onAbort = () => controller.abort();
|
|
540
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
541
|
+
if (options.signal?.aborted)
|
|
542
|
+
controller.abort();
|
|
384
543
|
let status;
|
|
385
544
|
let failed = true;
|
|
386
545
|
try {
|
|
@@ -394,6 +553,7 @@ export class BBRadarClient {
|
|
|
394
553
|
}
|
|
395
554
|
const response = await fetch(url, init);
|
|
396
555
|
status = response.status;
|
|
556
|
+
const noStore = /(?:^|,)\s*(?:no-store|no-cache|private)\b/i.test(response.headers.get("cache-control") ?? "");
|
|
397
557
|
this.observeCircuitResponse(rateLimitBucket, response.status);
|
|
398
558
|
this.observeUpstreamRateLimit(rateLimitBucket, response.headers, response.status);
|
|
399
559
|
const upstreamRequestId = extractRequestId(response.headers);
|
|
@@ -405,7 +565,8 @@ export class BBRadarClient {
|
|
|
405
565
|
requestId,
|
|
406
566
|
status: response.status,
|
|
407
567
|
detail: extractObjectField(data, "detail"),
|
|
408
|
-
errors: extractObjectField(data, "errors")
|
|
568
|
+
errors: extractObjectField(data, "errors"),
|
|
569
|
+
cacheAllowed: !noStore
|
|
409
570
|
};
|
|
410
571
|
if (upstreamRequestId) {
|
|
411
572
|
params.upstreamRequestId = upstreamRequestId;
|
|
@@ -415,7 +576,9 @@ export class BBRadarClient {
|
|
|
415
576
|
const result = {
|
|
416
577
|
data: data,
|
|
417
578
|
status: response.status,
|
|
418
|
-
fetchedAt: new Date().toISOString()
|
|
579
|
+
fetchedAt: new Date().toISOString(),
|
|
580
|
+
noStore,
|
|
581
|
+
cacheAllowed: !noStore
|
|
419
582
|
};
|
|
420
583
|
if (upstreamRequestId) {
|
|
421
584
|
result.upstreamRequestId = upstreamRequestId;
|
|
@@ -427,6 +590,12 @@ export class BBRadarClient {
|
|
|
427
590
|
if (error instanceof BBRadarApiError) {
|
|
428
591
|
throw error;
|
|
429
592
|
}
|
|
593
|
+
if (options.signal?.aborted) {
|
|
594
|
+
const timedOut = options.signal.reason instanceof Error && options.signal.reason.name === "TimeoutError";
|
|
595
|
+
if (timedOut)
|
|
596
|
+
this.observeCircuitFailure(rateLimitBucket);
|
|
597
|
+
throw new BBRadarApiError({ message: timedOut ? "BBRadar API tool deadline exceeded." : "BBRadar API request was cancelled.", requestId, status: 408 });
|
|
598
|
+
}
|
|
430
599
|
this.observeCircuitFailure(rateLimitBucket);
|
|
431
600
|
throw new BBRadarApiError({
|
|
432
601
|
message: error instanceof Error && error.name === "AbortError" ? "BBRadar API request timed out." : "BBRadar API request failed.",
|
|
@@ -436,9 +605,10 @@ export class BBRadarClient {
|
|
|
436
605
|
}
|
|
437
606
|
finally {
|
|
438
607
|
clearTimeout(timeout);
|
|
608
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
439
609
|
this.recordEndpointMetric(options.path, Date.now() - startedAt, status, failed);
|
|
440
610
|
}
|
|
441
|
-
});
|
|
611
|
+
}, deadline, requestId, options.signal);
|
|
442
612
|
}
|
|
443
613
|
refreshInBackground(options, method, url, requestKey) {
|
|
444
614
|
if (this.inFlight.has(requestKey)) {
|
|
@@ -448,7 +618,12 @@ export class BBRadarClient {
|
|
|
448
618
|
this.inFlight.set(requestKey, refresh);
|
|
449
619
|
void refresh
|
|
450
620
|
.then((result) => {
|
|
451
|
-
|
|
621
|
+
if (result.cacheAllowed !== false) {
|
|
622
|
+
this.cache.set(requestKey, result);
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
this.cache.delete(requestKey);
|
|
626
|
+
}
|
|
452
627
|
this.negativeCache.delete(requestKey);
|
|
453
628
|
})
|
|
454
629
|
.catch(() => undefined)
|
|
@@ -458,21 +633,21 @@ export class BBRadarClient {
|
|
|
458
633
|
}
|
|
459
634
|
});
|
|
460
635
|
}
|
|
461
|
-
async withRequestSlot(path, callback) {
|
|
636
|
+
async withRequestSlot(path, callback, deadline, requestId, signal) {
|
|
462
637
|
const endpointGroup = concurrencyEndpointGroup(path);
|
|
463
638
|
if (endpointGroup === undefined) {
|
|
464
|
-
return this.withGlobalRequestSlot(callback);
|
|
639
|
+
return this.withGlobalRequestSlot(callback, deadline, requestId, signal);
|
|
465
640
|
}
|
|
466
641
|
const endpointLimit = endpointConcurrencyLimit(endpointGroup);
|
|
467
642
|
if (endpointLimit === undefined) {
|
|
468
|
-
return this.withGlobalRequestSlot(callback);
|
|
643
|
+
return this.withGlobalRequestSlot(callback, deadline, requestId, signal);
|
|
469
644
|
}
|
|
470
|
-
return this.withEndpointRequestSlot(endpointGroup, endpointLimit, () => this.withGlobalRequestSlot(callback));
|
|
645
|
+
return this.withEndpointRequestSlot(endpointGroup, endpointLimit, () => this.withGlobalRequestSlot(callback, deadline, requestId, signal), deadline, requestId, signal);
|
|
471
646
|
}
|
|
472
|
-
async withGlobalRequestSlot(callback) {
|
|
647
|
+
async withGlobalRequestSlot(callback, deadline, requestId, signal) {
|
|
473
648
|
const limit = Math.max(1, this.config.maxConcurrentRequests);
|
|
474
649
|
if (this.activeRequests >= limit) {
|
|
475
|
-
await
|
|
650
|
+
await waitForRequestSlot(this.requestQueue, deadline, requestId, signal);
|
|
476
651
|
}
|
|
477
652
|
else {
|
|
478
653
|
this.activeRequests += 1;
|
|
@@ -490,14 +665,12 @@ export class BBRadarClient {
|
|
|
490
665
|
}
|
|
491
666
|
}
|
|
492
667
|
}
|
|
493
|
-
async withEndpointRequestSlot(endpointGroup, limit, callback) {
|
|
668
|
+
async withEndpointRequestSlot(endpointGroup, limit, callback, deadline, requestId, signal) {
|
|
494
669
|
const active = this.endpointActiveRequests.get(endpointGroup) ?? 0;
|
|
495
670
|
if (active >= limit) {
|
|
496
671
|
const queue = this.endpointRequestQueues.get(endpointGroup) ?? [];
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
this.endpointRequestQueues.set(endpointGroup, queue);
|
|
500
|
-
});
|
|
672
|
+
this.endpointRequestQueues.set(endpointGroup, queue);
|
|
673
|
+
await waitForRequestSlot(queue, deadline, requestId, signal);
|
|
501
674
|
}
|
|
502
675
|
else {
|
|
503
676
|
this.endpointActiveRequests.set(endpointGroup, active + 1);
|
|
@@ -787,7 +960,9 @@ export class BBRadarClient {
|
|
|
787
960
|
};
|
|
788
961
|
}
|
|
789
962
|
buildUrl(path, query) {
|
|
790
|
-
const
|
|
963
|
+
const base = this.config.apiBaseUrl.replace(/\/+$/, "");
|
|
964
|
+
const prefix = /\/v1$/i.test(base) ? base : `${base}/v1`;
|
|
965
|
+
const url = new URL(`${prefix}${path}`);
|
|
791
966
|
for (const [key, value] of Object.entries(query ?? {})) {
|
|
792
967
|
if (value === undefined || value === null) {
|
|
793
968
|
continue;
|
|
@@ -892,6 +1067,16 @@ function toIsoString(timestamp) {
|
|
|
892
1067
|
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
893
1068
|
}
|
|
894
1069
|
function metricEndpoint(path) {
|
|
1070
|
+
if (/^\/pro\/history\/changes\/.+/.test(path))
|
|
1071
|
+
return "/pro/history/changes/:change_id";
|
|
1072
|
+
if (/^\/pro\/history\/programs\/.+/.test(path))
|
|
1073
|
+
return "/pro/history/programs/:program_id";
|
|
1074
|
+
if (/^\/pro\/activity\/programs\/.+/.test(path))
|
|
1075
|
+
return "/pro/activity/programs/:program_id";
|
|
1076
|
+
if (/^\/pro\/analytics\/platforms\/.+\/series$/.test(path))
|
|
1077
|
+
return "/pro/analytics/platforms/:platform/series";
|
|
1078
|
+
if (/^\/pro\/analytics\/programs\/.+/.test(path) && path !== "/pro/analytics/programs/activity")
|
|
1079
|
+
return "/pro/analytics/programs/:program_id";
|
|
895
1080
|
if (/^\/pro\/programs\/.+\/targets$/.test(path)) {
|
|
896
1081
|
return "/pro/programs/:program_id/targets";
|
|
897
1082
|
}
|
|
@@ -904,7 +1089,7 @@ function metricEndpoint(path) {
|
|
|
904
1089
|
return path;
|
|
905
1090
|
}
|
|
906
1091
|
function rateLimitEndpointGroup(path) {
|
|
907
|
-
if (path === "/pro/targets/changes") {
|
|
1092
|
+
if (path === "/pro/targets/changes" || /^\/pro\/(?:history|activity|analytics)\//.test(path)) {
|
|
908
1093
|
return "change_feeds";
|
|
909
1094
|
}
|
|
910
1095
|
if (path === "/pro/repositories/changes") {
|
|
@@ -928,7 +1113,7 @@ function rateLimitEndpointGroup(path) {
|
|
|
928
1113
|
return "other";
|
|
929
1114
|
}
|
|
930
1115
|
function concurrencyEndpointGroup(path) {
|
|
931
|
-
if (path === "/pro/targets/changes" || path === "/pro/repositories/changes") {
|
|
1116
|
+
if (path === "/pro/targets/changes" || path === "/pro/repositories/changes" || /^\/pro\/(?:history|activity|analytics)\//.test(path)) {
|
|
932
1117
|
return "change_feeds";
|
|
933
1118
|
}
|
|
934
1119
|
if (path === "/pro/targets/export") {
|
|
@@ -945,6 +1130,25 @@ function endpointConcurrencyLimit(endpointGroup) {
|
|
|
945
1130
|
}
|
|
946
1131
|
return undefined;
|
|
947
1132
|
}
|
|
1133
|
+
function snapshotAwareReadControls(query, controls, defaultCache, defaultTimeoutMs) {
|
|
1134
|
+
const snapshot = typeof query.snapshot === "string" ? query.snapshot : undefined;
|
|
1135
|
+
const result = {
|
|
1136
|
+
// Snapshot responses are explicitly `Cache-Control: no-store`. Keeping
|
|
1137
|
+
// them out of the local TTL cache also prevents an expired token from
|
|
1138
|
+
// being replayed after its five-minute API lifetime.
|
|
1139
|
+
cache: controls.cache ?? (defaultCache && snapshot === undefined),
|
|
1140
|
+
// Two concurrent `snapshot=start` calls must create independent views.
|
|
1141
|
+
// Reusing an already-issued token can still coalesce identical live work.
|
|
1142
|
+
coalesce: controls.coalesce ?? snapshot !== "start"
|
|
1143
|
+
};
|
|
1144
|
+
const timeoutMs = controls.timeoutMs ?? (snapshot === "start"
|
|
1145
|
+
? Math.max(defaultTimeoutMs, SNAPSHOT_START_MIN_TIMEOUT_MS)
|
|
1146
|
+
: undefined);
|
|
1147
|
+
if (timeoutMs !== undefined) {
|
|
1148
|
+
result.timeoutMs = timeoutMs;
|
|
1149
|
+
}
|
|
1150
|
+
return result;
|
|
1151
|
+
}
|
|
948
1152
|
function isNegativeCacheStatus(status) {
|
|
949
1153
|
return status === 404 || status === 405;
|
|
950
1154
|
}
|
|
@@ -1041,6 +1245,7 @@ function cloneApiError(error, requestId, coalesced = error instanceof BBRadarApi
|
|
|
1041
1245
|
cached: error.cached,
|
|
1042
1246
|
coalesced,
|
|
1043
1247
|
cacheExpiresAt: error.cacheExpiresAt,
|
|
1248
|
+
cacheAllowed: error.cacheAllowed,
|
|
1044
1249
|
cause: error
|
|
1045
1250
|
});
|
|
1046
1251
|
}
|