@bbradar/mcp 0.1.19 → 0.1.20

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