github-issue-tower-defence-management 1.80.0 → 1.81.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +2 -2
  3. package/bin/adapter/entry-points/cli/index.js +35 -2
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/proxy/RateLimitCache.js +32 -4
  6. package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
  7. package/bin/adapter/proxy/proxyEntry.js +1 -1
  8. package/bin/adapter/proxy/proxyEntry.js.map +1 -1
  9. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js +3 -0
  10. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js.map +1 -1
  11. package/bin/domain/usecases/StartPreparationUseCase.js +8 -0
  12. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/adapter/entry-points/cli/index.ts +2 -1
  15. package/src/adapter/entry-points/handlers/rotationOrderFileWriter.test.ts +4 -0
  16. package/src/adapter/proxy/RateLimitCache.test.ts +103 -0
  17. package/src/adapter/proxy/RateLimitCache.ts +44 -2
  18. package/src/adapter/proxy/proxyEntry.test.ts +17 -0
  19. package/src/adapter/proxy/proxyEntry.ts +5 -1
  20. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +14 -0
  21. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +3 -0
  22. package/src/domain/entities/ClaudeTokenUsage.ts +1 -0
  23. package/src/domain/usecases/StartPreparationUseCase.test.ts +190 -0
  24. package/src/domain/usecases/StartPreparationUseCase.ts +14 -0
  25. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  26. package/types/adapter/proxy/RateLimitCache.d.ts +4 -1
  27. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  28. package/types/adapter/proxy/proxyEntry.d.ts.map +1 -1
  29. package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -1
  30. package/types/domain/entities/ClaudeTokenUsage.d.ts +1 -0
  31. package/types/domain/entities/ClaudeTokenUsage.d.ts.map +1 -1
  32. package/types/domain/usecases/StartPreparationUseCase.d.ts +2 -0
  33. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
@@ -5,6 +5,8 @@ import {
5
5
  cacheDir,
6
6
  cachePathForToken,
7
7
  hashToken,
8
+ HEADERLESS_429_DEFAULT_COOLDOWN_SECONDS,
9
+ HEADERLESS_429_MAX_COOLDOWN_SECONDS,
8
10
  parseModelRateLimitsFromBody,
9
11
  readRateLimit,
10
12
  writeModelRateLimit,
@@ -446,6 +448,107 @@ describe('RateLimitCache', () => {
446
448
  });
447
449
  });
448
450
 
451
+ describe('writeRateLimit records a cooldown on a 429 with no anthropic-ratelimit-* headers', () => {
452
+ it('should set blockedUntilEpoch using the default cooldown when no Retry-After header is present', () => {
453
+ const token = '429-no-headers-default-cooldown-token';
454
+ const before = Date.now() / 1000;
455
+ writeRateLimit(token, { 'content-type': 'application/json' }, 429);
456
+ const after = Date.now() / 1000;
457
+ const snapshot = readRateLimit(token);
458
+ expect(snapshot).not.toBeNull();
459
+ expect(snapshot?.blockedUntilEpoch).toBeGreaterThanOrEqual(
460
+ before + HEADERLESS_429_DEFAULT_COOLDOWN_SECONDS,
461
+ );
462
+ expect(snapshot?.blockedUntilEpoch).toBeLessThanOrEqual(
463
+ after + HEADERLESS_429_DEFAULT_COOLDOWN_SECONDS,
464
+ );
465
+ });
466
+
467
+ it('should honor the Retry-After header when present', () => {
468
+ const token = '429-no-headers-retry-after-token';
469
+ const retryAfterSeconds = 120;
470
+ const before = Date.now() / 1000;
471
+ writeRateLimit(
472
+ token,
473
+ { 'content-type': 'application/json', 'retry-after': '120' },
474
+ 429,
475
+ );
476
+ const after = Date.now() / 1000;
477
+ const snapshot = readRateLimit(token);
478
+ expect(snapshot?.blockedUntilEpoch).toBeGreaterThanOrEqual(
479
+ before + retryAfterSeconds,
480
+ );
481
+ expect(snapshot?.blockedUntilEpoch).toBeLessThanOrEqual(
482
+ after + retryAfterSeconds,
483
+ );
484
+ });
485
+
486
+ it('should clamp the cooldown to the maximum when Retry-After is very large', () => {
487
+ const token = '429-no-headers-clamp-token';
488
+ const before = Date.now() / 1000;
489
+ writeRateLimit(
490
+ token,
491
+ { 'content-type': 'application/json', 'retry-after': '99999' },
492
+ 429,
493
+ );
494
+ const after = Date.now() / 1000;
495
+ const snapshot = readRateLimit(token);
496
+ expect(snapshot?.blockedUntilEpoch).toBeGreaterThanOrEqual(
497
+ before + HEADERLESS_429_MAX_COOLDOWN_SECONDS,
498
+ );
499
+ expect(snapshot?.blockedUntilEpoch).toBeLessThanOrEqual(
500
+ after + HEADERLESS_429_MAX_COOLDOWN_SECONDS,
501
+ );
502
+ });
503
+
504
+ it('should preserve the previous last-good snapshot while adding the cooldown', () => {
505
+ const token = '429-no-headers-preserve-snapshot-token';
506
+ writeRateLimit(token, {
507
+ 'anthropic-ratelimit-unified-status': 'allowed',
508
+ 'anthropic-ratelimit-unified-5h-status': 'allowed',
509
+ 'anthropic-ratelimit-unified-5h-reset': '1700000000',
510
+ 'anthropic-ratelimit-unified-5h-utilization': '42',
511
+ 'anthropic-ratelimit-unified-7d-status': 'allowed',
512
+ 'anthropic-ratelimit-unified-7d-reset': '1700100000',
513
+ 'anthropic-ratelimit-unified-7d-utilization': '17',
514
+ });
515
+ writeRateLimit(token, { 'content-type': 'application/json' }, 429);
516
+ const snapshot = readRateLimit(token);
517
+ expect(snapshot?.fiveHourUtilization).toBe(42);
518
+ expect(snapshot?.fiveHourReset).toBe(1700000000);
519
+ expect(snapshot?.sevenDayUtilization).toBe(17);
520
+ expect(snapshot?.sevenDayReset).toBe(1700100000);
521
+ expect(snapshot?.rejected).toBe(false);
522
+ expect(snapshot?.blocked).toBe(false);
523
+ expect(snapshot?.blockedUntilEpoch).toBeGreaterThan(Date.now() / 1000);
524
+ });
525
+
526
+ it('should clear the cooldown when a later header-bearing response arrives', () => {
527
+ const token = '429-then-normal-clears-cooldown-token';
528
+ writeRateLimit(token, { 'content-type': 'application/json' }, 429);
529
+ expect(readRateLimit(token)?.blockedUntilEpoch).toBeGreaterThan(
530
+ Date.now() / 1000,
531
+ );
532
+ writeRateLimit(token, {
533
+ 'anthropic-ratelimit-unified-status': 'allowed',
534
+ 'anthropic-ratelimit-unified-5h-status': 'allowed',
535
+ 'anthropic-ratelimit-unified-5h-reset': '1700000000',
536
+ 'anthropic-ratelimit-unified-5h-utilization': '30',
537
+ 'anthropic-ratelimit-unified-7d-status': 'allowed',
538
+ 'anthropic-ratelimit-unified-7d-reset': '1700100000',
539
+ 'anthropic-ratelimit-unified-7d-utilization': '20',
540
+ });
541
+ expect(readRateLimit(token)?.blockedUntilEpoch).toBe(0);
542
+ });
543
+
544
+ it('should not write any cooldown for a headerless response that is not a 429', () => {
545
+ const token = '500-no-headers-no-cooldown-token';
546
+ writeRateLimit(token, { 'content-type': 'application/json' }, 500);
547
+ expect(fs.existsSync(cachePathForToken(token))).toBe(false);
548
+ expect(readRateLimit(token)).toBeNull();
549
+ });
550
+ });
551
+
449
552
  describe('parseModelRateLimitsFromBody', () => {
450
553
  it('should extract a rejected seven_day_sonnet limit from a rate_limit event body', () => {
451
554
  const body =
@@ -20,12 +20,17 @@ export interface RateLimitSnapshot {
20
20
  sevenDayRejected: boolean;
21
21
  modelWeeklyLimits: Record<string, ModelWeeklyLimit>;
22
22
  lastUpdatedEpoch: number;
23
+ blockedUntilEpoch: number;
23
24
  }
24
25
 
25
26
  export const PROXY_PORT = 8787;
26
27
 
27
28
  const HASH_ALGORITHM = 'sha256';
28
29
 
30
+ export const HEADERLESS_429_DEFAULT_COOLDOWN_SECONDS = 90;
31
+
32
+ export const HEADERLESS_429_MAX_COOLDOWN_SECONDS = 600;
33
+
29
34
  export const cacheDir = (): string => {
30
35
  const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
31
36
  return path.join(base, 'tdpm', 'ratelimit');
@@ -68,9 +73,21 @@ const readModelWeeklyLimits = (
68
73
  return result;
69
74
  };
70
75
 
76
+ const cooldownEndFromRetryAfter = (
77
+ retryAfterSeconds: number | null,
78
+ nowEpochSeconds: number,
79
+ ): number => {
80
+ const cooldownSeconds =
81
+ retryAfterSeconds !== null && retryAfterSeconds > 0
82
+ ? Math.min(retryAfterSeconds, HEADERLESS_429_MAX_COOLDOWN_SECONDS)
83
+ : HEADERLESS_429_DEFAULT_COOLDOWN_SECONDS;
84
+ return nowEpochSeconds + cooldownSeconds;
85
+ };
86
+
71
87
  export const writeRateLimit = (
72
88
  token: string,
73
89
  headers: Record<string, string | string[] | undefined>,
90
+ statusCode: number | null = null,
74
91
  ): void => {
75
92
  const pick = (key: string): string | undefined => {
76
93
  const value = headers[key];
@@ -86,14 +103,35 @@ export const writeRateLimit = (
86
103
  }
87
104
  }
88
105
  }
106
+ const dir = cacheDir();
107
+ const filePath = path.join(dir, `${hashToken(token)}.json`);
89
108
  if (Object.keys(rateLimitHeaders).length === 0) {
109
+ if (statusCode !== 429) {
110
+ return;
111
+ }
112
+ const existing = readPayload(filePath);
113
+ const retryAfterRaw = pick('retry-after');
114
+ const retryAfterSeconds =
115
+ retryAfterRaw !== undefined && Number.isFinite(Number(retryAfterRaw))
116
+ ? Number(retryAfterRaw)
117
+ : null;
118
+ const blockedUntilEpoch = cooldownEndFromRetryAfter(
119
+ retryAfterSeconds,
120
+ Date.now() / 1000,
121
+ );
122
+ if (!fs.existsSync(dir)) {
123
+ fs.mkdirSync(dir, { recursive: true });
124
+ }
125
+ const payload = {
126
+ ...existing,
127
+ blockedUntilEpoch,
128
+ };
129
+ fs.writeFileSync(filePath, JSON.stringify(payload));
90
130
  return;
91
131
  }
92
- const dir = cacheDir();
93
132
  if (!fs.existsSync(dir)) {
94
133
  fs.mkdirSync(dir, { recursive: true });
95
134
  }
96
- const filePath = path.join(dir, `${hashToken(token)}.json`);
97
135
  const existing = readPayload(filePath);
98
136
  const payload = {
99
137
  ts: Date.now() / 1000,
@@ -186,6 +224,9 @@ export const readRateLimit = (token: string): RateLimitSnapshot | null => {
186
224
  const sevenDayRejected = sevenDayStatus === 'rejected';
187
225
  const storedTs = parsed.ts;
188
226
  const lastUpdatedEpoch = typeof storedTs === 'number' ? storedTs : 0;
227
+ const storedBlockedUntil = parsed.blockedUntilEpoch;
228
+ const blockedUntilEpoch =
229
+ typeof storedBlockedUntil === 'number' ? storedBlockedUntil : 0;
189
230
  return {
190
231
  fiveHourUtilization: num('anthropic-ratelimit-unified-5h-utilization'),
191
232
  fiveHourReset: num('anthropic-ratelimit-unified-5h-reset'),
@@ -201,6 +242,7 @@ export const readRateLimit = (token: string): RateLimitSnapshot | null => {
201
242
  sevenDayRejected,
202
243
  modelWeeklyLimits: readModelWeeklyLimits(parsed),
203
244
  lastUpdatedEpoch,
245
+ blockedUntilEpoch,
204
246
  };
205
247
  } catch {
206
248
  return null;
@@ -291,6 +291,23 @@ describe('startProxy', () => {
291
291
  expect(writeModelRateLimitSpy).toHaveBeenCalledTimes(1);
292
292
  });
293
293
 
294
+ it('should forward the upstream status code to writeRateLimit on a 429 response', async () => {
295
+ upstreamHandler = (_request, response) => {
296
+ response.writeHead(429, { 'content-type': 'application/json' });
297
+ response.end('{"type":"error","error":{"type":"rate_limit_error"}}');
298
+ };
299
+
300
+ const response = await requestThroughProxy('POST', '/v1/messages', '{}');
301
+
302
+ expect(response.statusCode).toBe(429);
303
+ expect(writeRateLimitSpy).toHaveBeenCalledTimes(1);
304
+ expect(writeRateLimitSpy).toHaveBeenCalledWith(
305
+ TOKEN,
306
+ expect.objectContaining({ 'content-type': 'application/json' }),
307
+ 429,
308
+ );
309
+ });
310
+
294
311
  it('should forward non-SSE responses without crashing', async () => {
295
312
  upstreamHandler = (_request, response) => {
296
313
  response.writeHead(200, { 'content-type': 'application/json' });
@@ -50,7 +50,11 @@ const startProxy = (
50
50
  (upstreamResponse) => {
51
51
  if (token !== null) {
52
52
  try {
53
- writeRateLimit(token, upstreamResponse.headers);
53
+ writeRateLimit(
54
+ token,
55
+ upstreamResponse.headers,
56
+ upstreamResponse.statusCode ?? null,
57
+ );
54
58
  } catch (error) {
55
59
  console.error('Failed to write rate limit cache:', error);
56
60
  }
@@ -109,6 +109,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
109
109
  sevenDayUtilization: 0,
110
110
  blocked: false,
111
111
  rejected: false,
112
+ blockedUntilEpoch: 0,
112
113
  modelWeeklyLimits: {
113
114
  seven_day: { rejected: false, resetsAt: futureReset },
114
115
  },
@@ -120,6 +121,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
120
121
  sevenDayUtilization: 0,
121
122
  blocked: false,
122
123
  rejected: false,
124
+ blockedUntilEpoch: 0,
123
125
  modelWeeklyLimits: {},
124
126
  },
125
127
  ]);
@@ -153,6 +155,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
153
155
  sevenDayUtilization: 0,
154
156
  blocked: true,
155
157
  rejected: false,
158
+ blockedUntilEpoch: 0,
156
159
  modelWeeklyLimits: {
157
160
  seven_day: { rejected: false, resetsAt: futureReset },
158
161
  },
@@ -188,6 +191,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
188
191
  sevenDayUtilization: 0,
189
192
  blocked: false,
190
193
  rejected: true,
194
+ blockedUntilEpoch: 0,
191
195
  modelWeeklyLimits: {
192
196
  seven_day: { rejected: false, resetsAt: futureReset },
193
197
  },
@@ -223,6 +227,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
223
227
  sevenDayUtilization: 30,
224
228
  blocked: false,
225
229
  rejected: false,
230
+ blockedUntilEpoch: 0,
226
231
  modelWeeklyLimits: {
227
232
  seven_day: { rejected: false, resetsAt: futureReset },
228
233
  },
@@ -258,6 +263,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
258
263
  sevenDayUtilization: 0,
259
264
  blocked: false,
260
265
  rejected: false,
266
+ blockedUntilEpoch: 0,
261
267
  modelWeeklyLimits: {
262
268
  seven_day: { rejected: false, resetsAt: futureReset },
263
269
  },
@@ -293,6 +299,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
293
299
  sevenDayUtilization: 0,
294
300
  blocked: false,
295
301
  rejected: false,
302
+ blockedUntilEpoch: 0,
296
303
  modelWeeklyLimits: {
297
304
  seven_day: { rejected: false, resetsAt: futureReset },
298
305
  },
@@ -328,6 +335,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
328
335
  sevenDayUtilization: 0,
329
336
  blocked: false,
330
337
  rejected: false,
338
+ blockedUntilEpoch: 0,
331
339
  modelWeeklyLimits: {},
332
340
  },
333
341
  ]);
@@ -361,6 +369,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
361
369
  sevenDayUtilization: 0,
362
370
  blocked: false,
363
371
  rejected: true,
372
+ blockedUntilEpoch: 0,
364
373
  modelWeeklyLimits: {
365
374
  seven_day: { rejected: false, resetsAt: futureReset },
366
375
  },
@@ -396,6 +405,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
396
405
  sevenDayUtilization: 100,
397
406
  blocked: false,
398
407
  rejected: true,
408
+ blockedUntilEpoch: 0,
399
409
  modelWeeklyLimits: {
400
410
  seven_day: { rejected: true, resetsAt: futureReset },
401
411
  },
@@ -431,6 +441,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
431
441
  sevenDayUtilization: 0,
432
442
  blocked: false,
433
443
  rejected: false,
444
+ blockedUntilEpoch: 0,
434
445
  modelWeeklyLimits: {
435
446
  seven_day: { rejected: false, resetsAt: futureReset },
436
447
  },
@@ -455,6 +466,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
455
466
  sevenDayUtilization: 0,
456
467
  blocked: false,
457
468
  rejected: false,
469
+ blockedUntilEpoch: 0,
458
470
  modelWeeklyLimits: {},
459
471
  },
460
472
  ]);
@@ -490,6 +502,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
490
502
  sevenDayUtilization: 10,
491
503
  blocked: false,
492
504
  rejected: false,
505
+ blockedUntilEpoch: 0,
493
506
  modelWeeklyLimits: {
494
507
  seven_day_sonnet: { rejected: true, resetsAt: futureReset },
495
508
  },
@@ -527,6 +540,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
527
540
  sevenDayUtilization: 10,
528
541
  blocked: false,
529
542
  rejected: false,
543
+ blockedUntilEpoch: 0,
530
544
  modelWeeklyLimits: {
531
545
  seven_day_sonnet: { rejected: false, resetsAt: pastReset },
532
546
  },
@@ -36,6 +36,7 @@ export class ProxyClaudeTokenUsageRepository implements ClaudeTokenUsageReposito
36
36
  blocked: false,
37
37
  rejected: false,
38
38
  modelWeeklyLimits: {},
39
+ blockedUntilEpoch: 0,
39
40
  };
40
41
  }
41
42
  const fiveHourExpired = nowEpochSeconds > snapshot.fiveHourReset;
@@ -83,6 +84,7 @@ export class ProxyClaudeTokenUsageRepository implements ClaudeTokenUsageReposito
83
84
  resetsAt: snapshot.sevenDayReset,
84
85
  };
85
86
  }
87
+ const cooldownActive = snapshot.blockedUntilEpoch > nowEpochSeconds;
86
88
  return {
87
89
  name,
88
90
  token,
@@ -91,6 +93,7 @@ export class ProxyClaudeTokenUsageRepository implements ClaudeTokenUsageReposito
91
93
  blocked: snapshot.blocked,
92
94
  rejected,
93
95
  modelWeeklyLimits,
96
+ blockedUntilEpoch: cooldownActive ? snapshot.blockedUntilEpoch : 0,
94
97
  };
95
98
  });
96
99
  };
@@ -11,4 +11,5 @@ export type ClaudeTokenUsage = {
11
11
  blocked: boolean;
12
12
  rejected: boolean;
13
13
  modelWeeklyLimits: Record<string, ClaudeModelWeeklyLimit>;
14
+ blockedUntilEpoch: number;
14
15
  };