@bbradar/mcp 0.2.5 → 0.3.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/CHANGELOG.md +28 -0
- package/README.md +233 -10
- package/benchmark/compare-revisions.mjs +164 -0
- package/benchmark/pro-benchmark.mjs +106 -0
- package/dist/activityContract.d.ts +2 -0
- package/dist/activityContract.js +15 -0
- package/dist/activityContract.js.map +1 -0
- package/dist/bbradarClient.d.ts +21 -1
- package/dist/bbradarClient.js +263 -49
- package/dist/bbradarClient.js.map +1 -1
- package/dist/sanitize.d.ts +2 -0
- package/dist/sanitize.js +50 -13
- package/dist/sanitize.js.map +1 -1
- package/dist/server.js +853 -103
- package/dist/server.js.map +1 -1
- package/package.json +6 -4
package/dist/bbradarClient.js
CHANGED
|
@@ -1,7 +1,76 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { requiresActivityQuery } from "./activityContract.js";
|
|
3
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
4
|
import { TtlCache } from "./cache.js";
|
|
3
5
|
import { stableStringify } from "./json.js";
|
|
4
6
|
import { encodeProgramIdPath } from "./url.js";
|
|
7
|
+
function publicPayload(payload) {
|
|
8
|
+
const { cacheAllowed: _cacheAllowed, ...publicFields } = payload;
|
|
9
|
+
return publicFields;
|
|
10
|
+
}
|
|
11
|
+
function subscribeToRequest(promise, requestId, signal, shared) {
|
|
12
|
+
if (shared)
|
|
13
|
+
shared.consumers += 1;
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
let settled = false;
|
|
16
|
+
const finish = (callback) => {
|
|
17
|
+
if (settled)
|
|
18
|
+
return;
|
|
19
|
+
settled = true;
|
|
20
|
+
signal?.removeEventListener("abort", cancel);
|
|
21
|
+
if (shared)
|
|
22
|
+
shared.consumers -= 1;
|
|
23
|
+
callback();
|
|
24
|
+
};
|
|
25
|
+
const cancel = () => finish(() => {
|
|
26
|
+
if (shared?.consumers === 0)
|
|
27
|
+
shared.controller.abort(signal?.reason);
|
|
28
|
+
reject(new BBRadarApiError({ message: "BBRadar API request was cancelled or exceeded its tool deadline.", requestId, status: 408 }));
|
|
29
|
+
});
|
|
30
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
31
|
+
promise.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
|
|
32
|
+
if (signal?.aborted)
|
|
33
|
+
cancel();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function waitForRequestSlot(queue, deadline, requestId, signal) {
|
|
37
|
+
if (queue.length >= MAX_QUEUED_REQUESTS) {
|
|
38
|
+
throw new BBRadarApiError({ message: "BBRadar API request queue is full.", requestId, status: 503 });
|
|
39
|
+
}
|
|
40
|
+
if (deadline <= Date.now() || signal?.aborted) {
|
|
41
|
+
throw new BBRadarApiError({ message: "BBRadar API request timed out or was cancelled while queued.", requestId, status: 408 });
|
|
42
|
+
}
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
let settled = false;
|
|
45
|
+
const remove = () => {
|
|
46
|
+
const index = queue.indexOf(grant);
|
|
47
|
+
if (index >= 0)
|
|
48
|
+
queue.splice(index, 1);
|
|
49
|
+
};
|
|
50
|
+
const cleanup = () => {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
signal?.removeEventListener("abort", cancel);
|
|
53
|
+
};
|
|
54
|
+
const grant = () => {
|
|
55
|
+
if (settled)
|
|
56
|
+
return;
|
|
57
|
+
settled = true;
|
|
58
|
+
cleanup();
|
|
59
|
+
resolve();
|
|
60
|
+
};
|
|
61
|
+
const cancel = () => {
|
|
62
|
+
if (settled)
|
|
63
|
+
return;
|
|
64
|
+
settled = true;
|
|
65
|
+
remove();
|
|
66
|
+
cleanup();
|
|
67
|
+
reject(new BBRadarApiError({ message: "BBRadar API request timed out or was cancelled while queued.", requestId, status: 408 }));
|
|
68
|
+
};
|
|
69
|
+
const timer = setTimeout(cancel, Math.max(1, deadline - Date.now()));
|
|
70
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
71
|
+
queue.push(grant);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
5
74
|
const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60_000;
|
|
6
75
|
const MAX_UPSTREAM_RATE_LIMIT_DELAY_MS = 10 * 60_000;
|
|
7
76
|
const CHANGE_FEED_CONCURRENCY_LIMIT = 2;
|
|
@@ -14,6 +83,7 @@ const DEFAULT_PROGRAM_SWR_MS = 5 * 60_000;
|
|
|
14
83
|
const DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 3;
|
|
15
84
|
const DEFAULT_CIRCUIT_COOLDOWN_MS = 30_000;
|
|
16
85
|
const SNAPSHOT_START_MIN_TIMEOUT_MS = 60_000;
|
|
86
|
+
const MAX_QUEUED_REQUESTS = 100;
|
|
17
87
|
export class BBRadarApiError extends Error {
|
|
18
88
|
requestId;
|
|
19
89
|
upstreamRequestId;
|
|
@@ -23,6 +93,7 @@ export class BBRadarApiError extends Error {
|
|
|
23
93
|
cached;
|
|
24
94
|
coalesced;
|
|
25
95
|
cacheExpiresAt;
|
|
96
|
+
cacheAllowed;
|
|
26
97
|
constructor(params) {
|
|
27
98
|
super(params.message, { cause: params.cause });
|
|
28
99
|
this.name = "BBRadarApiError";
|
|
@@ -34,6 +105,7 @@ export class BBRadarApiError extends Error {
|
|
|
34
105
|
this.cached = params.cached ?? false;
|
|
35
106
|
this.coalesced = params.coalesced ?? false;
|
|
36
107
|
this.cacheExpiresAt = params.cacheExpiresAt;
|
|
108
|
+
this.cacheAllowed = params.cacheAllowed ?? true;
|
|
37
109
|
}
|
|
38
110
|
}
|
|
39
111
|
export class BBRadarClient {
|
|
@@ -41,6 +113,8 @@ export class BBRadarClient {
|
|
|
41
113
|
cache;
|
|
42
114
|
negativeCache;
|
|
43
115
|
inFlight = new Map();
|
|
116
|
+
sharedRequests = new Map();
|
|
117
|
+
requestContext = new AsyncLocalStorage();
|
|
44
118
|
endpointMetrics = new Map();
|
|
45
119
|
requestQueue = [];
|
|
46
120
|
endpointRequestQueues = new Map();
|
|
@@ -57,6 +131,24 @@ export class BBRadarClient {
|
|
|
57
131
|
this.cache = new TtlCache(config.cacheMaxEntries, config.cacheTtlMs, config.programStaleWhileRevalidateMs ?? DEFAULT_PROGRAM_SWR_MS);
|
|
58
132
|
this.negativeCache = new TtlCache(config.cacheMaxEntries, config.negativeCacheTtlMs);
|
|
59
133
|
}
|
|
134
|
+
async withRequestContext(signal, callback, snapshotStart = false) {
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
const onAbort = () => controller.abort(signal.reason);
|
|
137
|
+
const timeout = setTimeout(() => controller.abort(new DOMException("Tool deadline exceeded.", "TimeoutError")), snapshotStart
|
|
138
|
+
? Math.max(this.config.requestTimeoutMs, SNAPSHOT_START_MIN_TIMEOUT_MS)
|
|
139
|
+
: this.config.requestTimeoutMs);
|
|
140
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
141
|
+
if (signal.aborted)
|
|
142
|
+
onAbort();
|
|
143
|
+
try {
|
|
144
|
+
return await this.requestContext.run(controller.signal, callback);
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
clearTimeout(timeout);
|
|
148
|
+
signal.removeEventListener("abort", onAbort);
|
|
149
|
+
controller.abort();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
60
152
|
diagnostics() {
|
|
61
153
|
const endpointEntries = [...this.endpointMetrics.entries()];
|
|
62
154
|
const totalRequests = endpointEntries.reduce((total, [, metric]) => total + metric.requests, 0);
|
|
@@ -265,9 +357,46 @@ export class BBRadarClient {
|
|
|
265
357
|
getOpportunities(level, query) {
|
|
266
358
|
return this.request({
|
|
267
359
|
path: `/pro/opportunities/${encodeURIComponent(level)}`,
|
|
268
|
-
query
|
|
360
|
+
query,
|
|
361
|
+
...snapshotAwareReadControls(query, {}, true, this.config.requestTimeoutMs)
|
|
269
362
|
});
|
|
270
363
|
}
|
|
364
|
+
getHistoryChanges(query) {
|
|
365
|
+
return this.request({ path: "/pro/history/changes", query, cache: false });
|
|
366
|
+
}
|
|
367
|
+
getHistoryChangeDetail(changeId, query) {
|
|
368
|
+
return this.request({ path: `/pro/history/changes/${encodeURIComponent(changeId)}`, query, cache: false });
|
|
369
|
+
}
|
|
370
|
+
getProgramHistory(programId, query) {
|
|
371
|
+
return this.request({ path: `/pro/history/programs/${encodeProgramIdPath(programId)}`, query, cache: false });
|
|
372
|
+
}
|
|
373
|
+
getProgramActivity(programId) {
|
|
374
|
+
return this.request({ path: `/pro/activity/programs/${encodeProgramIdPath(programId)}`, cache: false });
|
|
375
|
+
}
|
|
376
|
+
getActivityCapabilities(query = {}) {
|
|
377
|
+
return this.request({ path: "/pro/activity/capabilities", query, cache: false });
|
|
378
|
+
}
|
|
379
|
+
getActivitySnapshot() {
|
|
380
|
+
return this.request({ path: "/pro/activity/snapshot", cache: false });
|
|
381
|
+
}
|
|
382
|
+
getActivityRankings(query) {
|
|
383
|
+
return this.request({ path: "/pro/activity/rankings", query, cache: false });
|
|
384
|
+
}
|
|
385
|
+
getActivityChangeDetail(eventId, query) {
|
|
386
|
+
return this.request({ path: `/pro/activity/events/${encodeURIComponent(eventId)}`, query, cache: false });
|
|
387
|
+
}
|
|
388
|
+
getProgramPayouts(programId, query = {}) {
|
|
389
|
+
return this.request({ path: `/pro/payouts/programs/${encodeProgramIdPath(programId)}`, query, cache: false });
|
|
390
|
+
}
|
|
391
|
+
getAnalyticsPlatformSeries(platform, query) {
|
|
392
|
+
return this.request({ path: `/pro/analytics/platforms/${encodeURIComponent(platform)}/series`, query, cache: false });
|
|
393
|
+
}
|
|
394
|
+
getAnalyticsProgramActivity(query) {
|
|
395
|
+
return this.request({ path: "/pro/analytics/programs/activity", query, cache: false });
|
|
396
|
+
}
|
|
397
|
+
getAnalyticsProgramDetail(programId, query) {
|
|
398
|
+
return this.request({ path: `/pro/analytics/programs/${encodeProgramIdPath(programId)}`, query, cache: false });
|
|
399
|
+
}
|
|
271
400
|
exportTargets(body) {
|
|
272
401
|
return this.request({
|
|
273
402
|
method: "POST",
|
|
@@ -278,8 +407,33 @@ export class BBRadarClient {
|
|
|
278
407
|
});
|
|
279
408
|
}
|
|
280
409
|
async request(options) {
|
|
410
|
+
const contextSignal = this.requestContext.getStore();
|
|
411
|
+
if (!options.signal || !contextSignal || options.signal === contextSignal) {
|
|
412
|
+
return this.requestWithSignal(options, options.signal ?? contextSignal);
|
|
413
|
+
}
|
|
414
|
+
// AbortSignal.any is unavailable on the earliest supported Node 20 releases.
|
|
415
|
+
// Remove both listeners after this caller settles, even for coalesced reads.
|
|
416
|
+
const controller = new AbortController();
|
|
417
|
+
const signals = [options.signal, contextSignal];
|
|
418
|
+
const listeners = signals.map((signal) => {
|
|
419
|
+
const abort = () => controller.abort(signal.reason);
|
|
420
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
421
|
+
if (signal.aborted)
|
|
422
|
+
abort();
|
|
423
|
+
return abort;
|
|
424
|
+
});
|
|
425
|
+
try {
|
|
426
|
+
return await this.requestWithSignal(options, controller.signal);
|
|
427
|
+
}
|
|
428
|
+
finally {
|
|
429
|
+
signals.forEach((signal, index) => signal.removeEventListener("abort", listeners[index]));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
async requestWithSignal(options, signal) {
|
|
281
433
|
const method = options.method ?? "GET";
|
|
282
434
|
const requestId = randomUUID();
|
|
435
|
+
if (signal?.aborted)
|
|
436
|
+
throw new BBRadarApiError({ message: "BBRadar API request was cancelled or exceeded its tool deadline.", requestId, status: 408 });
|
|
283
437
|
const url = this.buildUrl(options.path, options.query);
|
|
284
438
|
const requestKey = stableStringify({
|
|
285
439
|
method,
|
|
@@ -299,7 +453,7 @@ export class BBRadarClient {
|
|
|
299
453
|
this.refreshInBackground(options, method, url, requestKey);
|
|
300
454
|
}
|
|
301
455
|
return {
|
|
302
|
-
...cached.value,
|
|
456
|
+
...publicPayload(cached.value),
|
|
303
457
|
requestId,
|
|
304
458
|
cached: true,
|
|
305
459
|
stale: cached.stale,
|
|
@@ -320,11 +474,11 @@ export class BBRadarClient {
|
|
|
320
474
|
}
|
|
321
475
|
if (coalesceable) {
|
|
322
476
|
const pending = this.inFlight.get(requestKey);
|
|
323
|
-
if (pending) {
|
|
477
|
+
if (pending && !this.sharedRequests.get(requestKey)?.controller.signal.aborted) {
|
|
324
478
|
try {
|
|
325
|
-
const result = (await pending);
|
|
479
|
+
const result = (await subscribeToRequest(pending, requestId, signal, this.sharedRequests.get(requestKey)));
|
|
326
480
|
return {
|
|
327
|
-
...result,
|
|
481
|
+
...publicPayload(result),
|
|
328
482
|
requestId,
|
|
329
483
|
cached: false,
|
|
330
484
|
coalesced: true
|
|
@@ -335,27 +489,20 @@ export class BBRadarClient {
|
|
|
335
489
|
}
|
|
336
490
|
}
|
|
337
491
|
}
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
try {
|
|
343
|
-
const result = await request;
|
|
344
|
-
if (cacheable) {
|
|
492
|
+
const shared = coalesceable ? { controller: new AbortController(), consumers: 0 } : undefined;
|
|
493
|
+
const requestOptions = { ...options, ...(shared ? { signal: shared.controller.signal } : signal ? { signal } : {}) };
|
|
494
|
+
const request = this.fetchFromApi(requestOptions, method, url, requestId).then((result) => {
|
|
495
|
+
if (cacheable && result.cacheAllowed !== false) {
|
|
345
496
|
this.cache.set(requestKey, result);
|
|
346
497
|
}
|
|
347
498
|
if (negativeCacheable) {
|
|
348
499
|
this.negativeCache.delete(requestKey);
|
|
349
500
|
}
|
|
350
|
-
return
|
|
351
|
-
|
|
352
|
-
requestId,
|
|
353
|
-
cached: false
|
|
354
|
-
};
|
|
355
|
-
}
|
|
356
|
-
catch (error) {
|
|
501
|
+
return result;
|
|
502
|
+
}).catch((error) => {
|
|
357
503
|
if (negativeCacheable &&
|
|
358
504
|
error instanceof BBRadarApiError &&
|
|
505
|
+
error.cacheAllowed &&
|
|
359
506
|
error.status !== undefined &&
|
|
360
507
|
isNegativeCacheStatus(error.status)) {
|
|
361
508
|
this.negativeCache.set(requestKey, {
|
|
@@ -367,14 +514,21 @@ export class BBRadarClient {
|
|
|
367
514
|
});
|
|
368
515
|
}
|
|
369
516
|
throw error;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
if (coalesceable) {
|
|
517
|
+
}).finally(() => {
|
|
518
|
+
if (coalesceable && this.inFlight.get(requestKey) === request) {
|
|
373
519
|
this.inFlight.delete(requestKey);
|
|
520
|
+
this.sharedRequests.delete(requestKey);
|
|
374
521
|
}
|
|
522
|
+
});
|
|
523
|
+
if (coalesceable) {
|
|
524
|
+
this.inFlight.set(requestKey, request);
|
|
525
|
+
this.sharedRequests.set(requestKey, shared);
|
|
375
526
|
}
|
|
527
|
+
const result = await subscribeToRequest(request, requestId, signal, shared);
|
|
528
|
+
return { ...publicPayload(result), requestId, cached: false };
|
|
376
529
|
}
|
|
377
530
|
async fetchFromApi(options, method, url, requestId) {
|
|
531
|
+
const deadline = Date.now() + (options.timeoutMs ?? this.config.requestTimeoutMs);
|
|
378
532
|
const serializedBody = options.body === undefined ? undefined : JSON.stringify(options.body);
|
|
379
533
|
if (serializedBody !== undefined) {
|
|
380
534
|
const bodyBytes = Buffer.byteLength(serializedBody, "utf8");
|
|
@@ -387,13 +541,23 @@ export class BBRadarClient {
|
|
|
387
541
|
});
|
|
388
542
|
}
|
|
389
543
|
}
|
|
390
|
-
return this.withRequestSlot(options.path, async () => {
|
|
391
|
-
const rateLimitBucket = rateLimitEndpointGroup(options.path);
|
|
392
|
-
|
|
544
|
+
return this.withRequestSlot(options.path, options.query, async () => {
|
|
545
|
+
const rateLimitBucket = rateLimitEndpointGroup(options.path, options.query);
|
|
546
|
+
const circuitBucket = activityCircuitBucket(options.path, options.query) ?? rateLimitBucket;
|
|
547
|
+
if (options.signal?.aborted)
|
|
548
|
+
throw new BBRadarApiError({ message: "BBRadar API request was cancelled.", requestId, status: 408 });
|
|
549
|
+
this.assertCircuitClosed(requestId, circuitBucket);
|
|
393
550
|
this.reserveUpstreamRequest(requestId, rateLimitBucket);
|
|
394
551
|
const startedAt = Date.now();
|
|
395
552
|
const controller = new AbortController();
|
|
396
|
-
const
|
|
553
|
+
const remainingMs = deadline - Date.now();
|
|
554
|
+
if (remainingMs <= 0)
|
|
555
|
+
throw new BBRadarApiError({ message: "BBRadar API request timed out while queued.", requestId, status: 408 });
|
|
556
|
+
const timeout = setTimeout(() => controller.abort(), remainingMs);
|
|
557
|
+
const onAbort = () => controller.abort();
|
|
558
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
559
|
+
if (options.signal?.aborted)
|
|
560
|
+
controller.abort();
|
|
397
561
|
let status;
|
|
398
562
|
let failed = true;
|
|
399
563
|
try {
|
|
@@ -407,7 +571,8 @@ export class BBRadarClient {
|
|
|
407
571
|
}
|
|
408
572
|
const response = await fetch(url, init);
|
|
409
573
|
status = response.status;
|
|
410
|
-
|
|
574
|
+
const noStore = /(?:^|,)\s*(?:no-store|no-cache|private)\b/i.test(response.headers.get("cache-control") ?? "");
|
|
575
|
+
this.observeCircuitResponse(circuitBucket, response.status);
|
|
411
576
|
this.observeUpstreamRateLimit(rateLimitBucket, response.headers, response.status);
|
|
412
577
|
const upstreamRequestId = extractRequestId(response.headers);
|
|
413
578
|
const data = await parseResponseBody(response);
|
|
@@ -418,7 +583,8 @@ export class BBRadarClient {
|
|
|
418
583
|
requestId,
|
|
419
584
|
status: response.status,
|
|
420
585
|
detail: extractObjectField(data, "detail"),
|
|
421
|
-
errors: extractObjectField(data, "errors")
|
|
586
|
+
errors: extractObjectField(data, "errors"),
|
|
587
|
+
cacheAllowed: !noStore
|
|
422
588
|
};
|
|
423
589
|
if (upstreamRequestId) {
|
|
424
590
|
params.upstreamRequestId = upstreamRequestId;
|
|
@@ -428,7 +594,9 @@ export class BBRadarClient {
|
|
|
428
594
|
const result = {
|
|
429
595
|
data: data,
|
|
430
596
|
status: response.status,
|
|
431
|
-
fetchedAt: new Date().toISOString()
|
|
597
|
+
fetchedAt: new Date().toISOString(),
|
|
598
|
+
noStore,
|
|
599
|
+
cacheAllowed: !noStore
|
|
432
600
|
};
|
|
433
601
|
if (upstreamRequestId) {
|
|
434
602
|
result.upstreamRequestId = upstreamRequestId;
|
|
@@ -440,7 +608,13 @@ export class BBRadarClient {
|
|
|
440
608
|
if (error instanceof BBRadarApiError) {
|
|
441
609
|
throw error;
|
|
442
610
|
}
|
|
443
|
-
|
|
611
|
+
if (options.signal?.aborted) {
|
|
612
|
+
const timedOut = options.signal.reason instanceof Error && options.signal.reason.name === "TimeoutError";
|
|
613
|
+
if (timedOut)
|
|
614
|
+
this.observeCircuitFailure(circuitBucket);
|
|
615
|
+
throw new BBRadarApiError({ message: timedOut ? "BBRadar API tool deadline exceeded." : "BBRadar API request was cancelled.", requestId, status: 408 });
|
|
616
|
+
}
|
|
617
|
+
this.observeCircuitFailure(circuitBucket);
|
|
444
618
|
throw new BBRadarApiError({
|
|
445
619
|
message: error instanceof Error && error.name === "AbortError" ? "BBRadar API request timed out." : "BBRadar API request failed.",
|
|
446
620
|
requestId,
|
|
@@ -449,9 +623,10 @@ export class BBRadarClient {
|
|
|
449
623
|
}
|
|
450
624
|
finally {
|
|
451
625
|
clearTimeout(timeout);
|
|
626
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
452
627
|
this.recordEndpointMetric(options.path, Date.now() - startedAt, status, failed);
|
|
453
628
|
}
|
|
454
|
-
});
|
|
629
|
+
}, deadline, requestId, options.signal);
|
|
455
630
|
}
|
|
456
631
|
refreshInBackground(options, method, url, requestKey) {
|
|
457
632
|
if (this.inFlight.has(requestKey)) {
|
|
@@ -461,7 +636,12 @@ export class BBRadarClient {
|
|
|
461
636
|
this.inFlight.set(requestKey, refresh);
|
|
462
637
|
void refresh
|
|
463
638
|
.then((result) => {
|
|
464
|
-
|
|
639
|
+
if (result.cacheAllowed !== false) {
|
|
640
|
+
this.cache.set(requestKey, result);
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
this.cache.delete(requestKey);
|
|
644
|
+
}
|
|
465
645
|
this.negativeCache.delete(requestKey);
|
|
466
646
|
})
|
|
467
647
|
.catch(() => undefined)
|
|
@@ -471,21 +651,21 @@ export class BBRadarClient {
|
|
|
471
651
|
}
|
|
472
652
|
});
|
|
473
653
|
}
|
|
474
|
-
async withRequestSlot(path, callback) {
|
|
475
|
-
const endpointGroup = concurrencyEndpointGroup(path);
|
|
654
|
+
async withRequestSlot(path, query, callback, deadline, requestId, signal) {
|
|
655
|
+
const endpointGroup = concurrencyEndpointGroup(path, query);
|
|
476
656
|
if (endpointGroup === undefined) {
|
|
477
|
-
return this.withGlobalRequestSlot(callback);
|
|
657
|
+
return this.withGlobalRequestSlot(callback, deadline, requestId, signal);
|
|
478
658
|
}
|
|
479
659
|
const endpointLimit = endpointConcurrencyLimit(endpointGroup);
|
|
480
660
|
if (endpointLimit === undefined) {
|
|
481
|
-
return this.withGlobalRequestSlot(callback);
|
|
661
|
+
return this.withGlobalRequestSlot(callback, deadline, requestId, signal);
|
|
482
662
|
}
|
|
483
|
-
return this.withEndpointRequestSlot(endpointGroup, endpointLimit, () => this.withGlobalRequestSlot(callback));
|
|
663
|
+
return this.withEndpointRequestSlot(endpointGroup, endpointLimit, () => this.withGlobalRequestSlot(callback, deadline, requestId, signal), deadline, requestId, signal);
|
|
484
664
|
}
|
|
485
|
-
async withGlobalRequestSlot(callback) {
|
|
665
|
+
async withGlobalRequestSlot(callback, deadline, requestId, signal) {
|
|
486
666
|
const limit = Math.max(1, this.config.maxConcurrentRequests);
|
|
487
667
|
if (this.activeRequests >= limit) {
|
|
488
|
-
await
|
|
668
|
+
await waitForRequestSlot(this.requestQueue, deadline, requestId, signal);
|
|
489
669
|
}
|
|
490
670
|
else {
|
|
491
671
|
this.activeRequests += 1;
|
|
@@ -503,14 +683,12 @@ export class BBRadarClient {
|
|
|
503
683
|
}
|
|
504
684
|
}
|
|
505
685
|
}
|
|
506
|
-
async withEndpointRequestSlot(endpointGroup, limit, callback) {
|
|
686
|
+
async withEndpointRequestSlot(endpointGroup, limit, callback, deadline, requestId, signal) {
|
|
507
687
|
const active = this.endpointActiveRequests.get(endpointGroup) ?? 0;
|
|
508
688
|
if (active >= limit) {
|
|
509
689
|
const queue = this.endpointRequestQueues.get(endpointGroup) ?? [];
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
this.endpointRequestQueues.set(endpointGroup, queue);
|
|
513
|
-
});
|
|
690
|
+
this.endpointRequestQueues.set(endpointGroup, queue);
|
|
691
|
+
await waitForRequestSlot(queue, deadline, requestId, signal);
|
|
514
692
|
}
|
|
515
693
|
else {
|
|
516
694
|
this.endpointActiveRequests.set(endpointGroup, active + 1);
|
|
@@ -800,7 +978,9 @@ export class BBRadarClient {
|
|
|
800
978
|
};
|
|
801
979
|
}
|
|
802
980
|
buildUrl(path, query) {
|
|
803
|
-
const
|
|
981
|
+
const base = this.config.apiBaseUrl.replace(/\/+$/, "");
|
|
982
|
+
const prefix = /\/v1$/i.test(base) ? base : `${base}/v1`;
|
|
983
|
+
const url = new URL(`${prefix}${path}`);
|
|
804
984
|
for (const [key, value] of Object.entries(query ?? {})) {
|
|
805
985
|
if (value === undefined || value === null) {
|
|
806
986
|
continue;
|
|
@@ -905,6 +1085,20 @@ function toIsoString(timestamp) {
|
|
|
905
1085
|
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
906
1086
|
}
|
|
907
1087
|
function metricEndpoint(path) {
|
|
1088
|
+
if (/^\/pro\/history\/changes\/.+/.test(path))
|
|
1089
|
+
return "/pro/history/changes/:change_id";
|
|
1090
|
+
if (/^\/pro\/history\/programs\/.+/.test(path))
|
|
1091
|
+
return "/pro/history/programs/:program_id";
|
|
1092
|
+
if (/^\/pro\/activity\/programs\/.+/.test(path))
|
|
1093
|
+
return "/pro/activity/programs/:program_id";
|
|
1094
|
+
if (/^\/pro\/activity\/events\/.+/.test(path))
|
|
1095
|
+
return "/pro/activity/events/:event_id";
|
|
1096
|
+
if (/^\/pro\/payouts\/programs\/.+/.test(path))
|
|
1097
|
+
return "/pro/payouts/programs/:program_id";
|
|
1098
|
+
if (/^\/pro\/analytics\/platforms\/.+\/series$/.test(path))
|
|
1099
|
+
return "/pro/analytics/platforms/:platform/series";
|
|
1100
|
+
if (/^\/pro\/analytics\/programs\/.+/.test(path) && path !== "/pro/analytics/programs/activity")
|
|
1101
|
+
return "/pro/analytics/programs/:program_id";
|
|
908
1102
|
if (/^\/pro\/programs\/.+\/targets$/.test(path)) {
|
|
909
1103
|
return "/pro/programs/:program_id/targets";
|
|
910
1104
|
}
|
|
@@ -916,8 +1110,26 @@ function metricEndpoint(path) {
|
|
|
916
1110
|
}
|
|
917
1111
|
return path;
|
|
918
1112
|
}
|
|
919
|
-
|
|
920
|
-
|
|
1113
|
+
// The API admits Activity-filtered catalog reads in its heavy "changes" group,
|
|
1114
|
+
// sharing that quota and in-flight limit with the change feeds.
|
|
1115
|
+
function isActivityCatalogRead(path, query = {}) {
|
|
1116
|
+
return (path === "/pro/programs" || path.startsWith("/pro/opportunities/")) && requiresActivityQuery(query);
|
|
1117
|
+
}
|
|
1118
|
+
function activityCircuitBucket(path, query = {}) {
|
|
1119
|
+
const activityCatalog = isActivityCatalogRead(path, query);
|
|
1120
|
+
// Chronological feeds can exceed their own projection budget while complete
|
|
1121
|
+
// rankings remain healthy. Keep this a finite group, never a caller-keyed map.
|
|
1122
|
+
if ((path === "/pro/activity/rankings" && (query.tab === "status" || query.tab === "all_changes"))
|
|
1123
|
+
|| path.startsWith("/pro/activity/events/")
|
|
1124
|
+
|| (activityCatalog && query.status_change !== undefined && query.status_change !== null)) {
|
|
1125
|
+
return "activity_chronological";
|
|
1126
|
+
}
|
|
1127
|
+
if (/^\/pro\/(?:activity|payouts)\//.test(path))
|
|
1128
|
+
return "activity_reads";
|
|
1129
|
+
return activityCatalog ? "activity_catalog" : undefined;
|
|
1130
|
+
}
|
|
1131
|
+
function rateLimitEndpointGroup(path, query) {
|
|
1132
|
+
if (path === "/pro/targets/changes" || /^\/pro\/(?:history|activity|analytics|payouts)\//.test(path) || isActivityCatalogRead(path, query)) {
|
|
921
1133
|
return "change_feeds";
|
|
922
1134
|
}
|
|
923
1135
|
if (path === "/pro/repositories/changes") {
|
|
@@ -940,8 +1152,9 @@ function rateLimitEndpointGroup(path) {
|
|
|
940
1152
|
}
|
|
941
1153
|
return "other";
|
|
942
1154
|
}
|
|
943
|
-
function concurrencyEndpointGroup(path) {
|
|
944
|
-
if (path === "/pro/targets/changes" || path === "/pro/repositories/changes")
|
|
1155
|
+
function concurrencyEndpointGroup(path, query) {
|
|
1156
|
+
if (path === "/pro/targets/changes" || path === "/pro/repositories/changes" || /^\/pro\/(?:history|activity|analytics|payouts)\//.test(path)
|
|
1157
|
+
|| isActivityCatalogRead(path, query)) {
|
|
945
1158
|
return "change_feeds";
|
|
946
1159
|
}
|
|
947
1160
|
if (path === "/pro/targets/export") {
|
|
@@ -1073,6 +1286,7 @@ function cloneApiError(error, requestId, coalesced = error instanceof BBRadarApi
|
|
|
1073
1286
|
cached: error.cached,
|
|
1074
1287
|
coalesced,
|
|
1075
1288
|
cacheExpiresAt: error.cacheExpiresAt,
|
|
1289
|
+
cacheAllowed: error.cacheAllowed,
|
|
1076
1290
|
cause: error
|
|
1077
1291
|
});
|
|
1078
1292
|
}
|