@fink-andreas/pi-linear-tools 0.5.1 → 0.7.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.
@@ -11,7 +11,11 @@ import { debug, warn, info } from './logger.js';
11
11
  /** @type {Function|null} Test-only client factory override */
12
12
  let _testClientFactory = null;
13
13
 
14
- /** @type {Map<string, {remaining: number, resetAt: number}>} Per-client rate limit tracking */
14
+ const DEFAULT_REQUEST_LIMIT = 5000;
15
+ const LOW_RATE_LIMIT_THRESHOLD = 0.10;
16
+ const RATE_LIMIT_WARN_MIN_MS = 30000;
17
+
18
+ /** @type {Map<string, {limit: number, remaining: number, resetAt: number, lastWarnAt?: number}>} Per-client request rate limit tracking */
15
19
  const rateLimitTracker = new Map();
16
20
 
17
21
  /** @type {Map<string, {total: number, success: number, failed: number, rateLimited: number, windowStart: number, lastSummaryAt: number}>} */
@@ -32,6 +36,33 @@ function getTrackerKeyFromClient(client) {
32
36
  return client?.__piLinearTrackerKey || client?.apiKey || 'default';
33
37
  }
34
38
 
39
+ function parseHeaderNumber(value) {
40
+ if (value === null || value === undefined) return null;
41
+ const parsed = Number.parseInt(String(value), 10);
42
+ return Number.isFinite(parsed) ? parsed : null;
43
+ }
44
+
45
+ function getTrackerLimit(tracker) {
46
+ return tracker?.limit || tracker?.total || DEFAULT_REQUEST_LIMIT;
47
+ }
48
+
49
+ function getUsedRequests(tracker) {
50
+ const limit = getTrackerLimit(tracker);
51
+ const remaining = tracker?.remaining;
52
+ if (!Number.isFinite(remaining)) return 0;
53
+ return Math.max(0, limit - remaining);
54
+ }
55
+
56
+ function getUsagePercent(tracker) {
57
+ const limit = getTrackerLimit(tracker);
58
+ if (!limit) return null;
59
+ return Math.round((getUsedRequests(tracker) / limit) * 100);
60
+ }
61
+
62
+ function getLowRequestThreshold(tracker) {
63
+ return Math.max(1, Math.floor(getTrackerLimit(tracker) * LOW_RATE_LIMIT_THRESHOLD));
64
+ }
65
+
35
66
  function getRequestMetric(trackerKey) {
36
67
  let metric = requestMetrics.get(trackerKey);
37
68
  if (!metric) {
@@ -60,7 +91,8 @@ function maybeLogRequestSummary(trackerKey) {
60
91
  if (!shouldLogByCount && !shouldLogByTime) return;
61
92
 
62
93
  metric.lastSummaryAt = now;
63
- const used = Math.max(0, 5000 - (tracker.remaining ?? 5000));
94
+ const used = getUsedRequests(tracker);
95
+ const limit = getTrackerLimit(tracker);
64
96
 
65
97
  info('[pi-linear-tools] Linear API usage summary', {
66
98
  trackerKey,
@@ -68,6 +100,7 @@ function maybeLogRequestSummary(trackerKey) {
68
100
  requestsSuccess: metric.success,
69
101
  requestsFailed: metric.failed,
70
102
  requestsRateLimited: metric.rateLimited,
103
+ requestsLimit: limit,
71
104
  requestsRemaining: tracker.remaining,
72
105
  requestsUsed: used,
73
106
  resetAt: tracker.resetAt,
@@ -125,19 +158,54 @@ export function markRateLimited(resetAt) {
125
158
  * Extract rate limit info from SDK client response
126
159
  * The Linear SDK stores response metadata on the client after requests
127
160
  * @param {LinearClient} client - Linear SDK client
128
- * @returns {{remaining: number|null, resetAt: number|null, resetTime: string|null}}
161
+ * @returns {{limit: number|null, remaining: number|null, resetAt: number|null, resetTime: string|null}}
129
162
  */
130
163
  export function getClientRateLimit(client) {
131
164
  const trackerData = rateLimitTracker.get(getTrackerKeyFromClient(client));
132
165
  if (trackerData) {
133
166
  return {
167
+ limit: getTrackerLimit(trackerData),
134
168
  remaining: trackerData.remaining,
135
169
  resetAt: trackerData.resetAt,
136
170
  resetTime: trackerData.resetAt ? new Date(trackerData.resetAt).toLocaleTimeString() : null,
137
171
  };
138
172
  }
139
173
 
140
- return { remaining: null, resetAt: null, resetTime: null };
174
+ return { limit: null, remaining: null, resetAt: null, resetTime: null };
175
+ }
176
+
177
+ /**
178
+ * Get detailed rate limit info including usage percentage
179
+ * @param {LinearClient} client - Linear SDK client
180
+ * @returns {{remaining: number|null, resetAt: number|null, resetTime: string|null, used: number, usagePercent: number|null, total: number}}
181
+ */
182
+ export function getClientRateLimitInfo(client) {
183
+ const trackerData = rateLimitTracker.get(getTrackerKeyFromClient(client));
184
+ const total = getTrackerLimit(trackerData);
185
+
186
+ if (trackerData && trackerData.remaining !== undefined) {
187
+ const remaining = trackerData.remaining;
188
+ const used = getUsedRequests(trackerData);
189
+ const usagePercent = getUsagePercent(trackerData);
190
+
191
+ return {
192
+ remaining,
193
+ resetAt: trackerData.resetAt,
194
+ resetTime: trackerData.resetAt ? new Date(trackerData.resetAt).toLocaleTimeString() : null,
195
+ used,
196
+ usagePercent,
197
+ total,
198
+ };
199
+ }
200
+
201
+ return {
202
+ remaining: null,
203
+ resetAt: null,
204
+ resetTime: null,
205
+ used: 0,
206
+ usagePercent: null,
207
+ total,
208
+ };
141
209
  }
142
210
 
143
211
  /**
@@ -172,11 +240,12 @@ export function getClientRequestMetrics(client) {
172
240
  * @returns {boolean} True if warning was issued
173
241
  */
174
242
  export function checkAndWarnRateLimit(client) {
175
- const { remaining, resetTime } = getClientRateLimit(client);
243
+ const rateLimitInfo = getClientRateLimitInfo(client);
244
+ const { remaining, resetTime, usagePercent } = rateLimitInfo;
176
245
 
177
- if (remaining !== null && remaining <= 500) {
178
- const usagePercent = Math.round(((5000 - remaining) / 5000) * 100);
246
+ if (remaining !== null && remaining <= getLowRequestThreshold(rateLimitInfo)) {
179
247
  warn(`Linear API rate limit running low: ${remaining} requests remaining (~${usagePercent}% used). Resets at ${resetTime}`, {
248
+ limit: rateLimitInfo.total,
180
249
  remaining,
181
250
  resetTime,
182
251
  usagePercent,
@@ -233,7 +302,12 @@ export function createLinearClient(auth) {
233
302
  const trackerKey = getTrackerKey(apiKey);
234
303
  client.__piLinearTrackerKey = trackerKey;
235
304
  if (!rateLimitTracker.has(trackerKey)) {
236
- rateLimitTracker.set(trackerKey, { remaining: 5000, resetAt: Date.now() + 3600000 });
305
+ rateLimitTracker.set(trackerKey, {
306
+ limit: DEFAULT_REQUEST_LIMIT,
307
+ remaining: DEFAULT_REQUEST_LIMIT,
308
+ resetAt: Date.now() + 3600000,
309
+ lastWarnAt: 0,
310
+ });
237
311
  }
238
312
  getRequestMetric(trackerKey);
239
313
 
@@ -254,22 +328,34 @@ export function createLinearClient(auth) {
254
328
  metric.success += 1;
255
329
 
256
330
  if (response.headers) {
257
- const remaining = response.headers.get('X-RateLimit-Requests-Remaining');
258
- const resetAt = response.headers.get('X-RateLimit-Requests-Reset');
331
+ const limit = parseHeaderNumber(response.headers.get('X-RateLimit-Requests-Limit'));
332
+ const remaining = parseHeaderNumber(response.headers.get('X-RateLimit-Requests-Remaining'));
333
+ const resetAt = parseHeaderNumber(response.headers.get('X-RateLimit-Requests-Reset'));
259
334
 
260
335
  const tracker = rateLimitTracker.get(trackerKey);
336
+ if (tracker && limit !== null) {
337
+ tracker.limit = limit;
338
+ }
261
339
  if (tracker && remaining !== null) {
262
- tracker.remaining = parseInt(remaining, 10);
340
+ tracker.remaining = remaining;
263
341
  }
264
342
  if (tracker && resetAt !== null) {
265
- tracker.resetAt = parseInt(resetAt, 10);
343
+ tracker.resetAt = resetAt;
266
344
  }
267
345
  }
268
346
 
269
347
  const tracker = rateLimitTracker.get(trackerKey);
270
- if (tracker && tracker.remaining <= 500 && tracker.remaining > 0) {
271
- const usagePercent = Math.round(((5000 - tracker.remaining) / 5000) * 100);
348
+ const now = Date.now();
349
+ if (
350
+ tracker &&
351
+ tracker.remaining <= getLowRequestThreshold(tracker) &&
352
+ tracker.remaining > 0 &&
353
+ now - (tracker.lastWarnAt || 0) >= RATE_LIMIT_WARN_MIN_MS
354
+ ) {
355
+ tracker.lastWarnAt = now;
356
+ const usagePercent = getUsagePercent(tracker);
272
357
  warn(`Linear API rate limit running low: ${tracker.remaining} requests remaining (~${usagePercent}% used). Resets at ${new Date(tracker.resetAt).toLocaleTimeString()}`, {
358
+ limit: getTrackerLimit(tracker),
273
359
  remaining: tracker.remaining,
274
360
  resetTime: new Date(tracker.resetAt).toLocaleTimeString(),
275
361
  usagePercent,
@@ -288,9 +374,12 @@ export function createLinearClient(auth) {
288
374
  metric.rateLimited += 1;
289
375
  const resetAt = Number(error?.requestsResetAt) || Date.now() + 3600000;
290
376
  const remaining = Number.isFinite(error?.requestsRemaining) ? error.requestsRemaining : 0;
377
+ const previousTracker = rateLimitTracker.get(trackerKey);
291
378
  rateLimitTracker.set(trackerKey, {
379
+ limit: getTrackerLimit(previousTracker),
292
380
  remaining,
293
381
  resetAt,
382
+ lastWarnAt: previousTracker?.lastWarnAt || 0,
294
383
  });
295
384
  markRateLimited(resetAt);
296
385
  }
@@ -317,3 +406,20 @@ export function setTestClientFactory(factory) {
317
406
  export function resetTestClientFactory() {
318
407
  _testClientFactory = null;
319
408
  }
409
+
410
+ /**
411
+ * Set rate limit tracker state for testing (TEST ONLY)
412
+ * @param {string} apiKey - API key used to create the client
413
+ * @param {{remaining: number, resetAt: number}} state - Tracker state
414
+ */
415
+ export function setTestRateLimitTracker(apiKey, state) {
416
+ const trackerKey = getTrackerKey(apiKey);
417
+ rateLimitTracker.set(trackerKey, state);
418
+ }
419
+
420
+ /**
421
+ * Clear rate limit tracker for testing (TEST ONLY)
422
+ */
423
+ export function clearTestRateLimitTracker() {
424
+ rateLimitTracker.clear();
425
+ }