diff --git a/packages/annotator/src/__tests__/client.test.ts b/packages/annotator/src/__tests__/client.test.ts index 475b2b8..2edaaff 100644 --- a/packages/annotator/src/__tests__/client.test.ts +++ b/packages/annotator/src/__tests__/client.test.ts @@ -2,6 +2,20 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { createLogger } from '@civic-source/shared'; import { CourtListenerClient, isCourtListenerResult } from '../client.js'; +import { COURTLISTENER_RATE_LIMITER, RATE_LIMIT_PER_HOUR } from '../constants.js'; + +describe('COURTLISTENER_RATE_LIMITER (#230)', () => { + it('sustains exactly RATE_LIMIT_PER_HOUR tokens per hour (not the old ~7200)', () => { + const perHour = + (3_600_000 / COURTLISTENER_RATE_LIMITER.refillIntervalMs) * COURTLISTENER_RATE_LIMITER.refillRate; + expect(perHour).toBeLessThanOrEqual(RATE_LIMIT_PER_HOUR); + expect(perHour).toBe(RATE_LIMIT_PER_HOUR); + }); + + it('caps burst capacity at the hourly limit', () => { + expect(COURTLISTENER_RATE_LIMITER.capacity).toBe(RATE_LIMIT_PER_HOUR); + }); +}); const VALID = { caseName: 'Doe v. United States', diff --git a/packages/annotator/src/annotator.ts b/packages/annotator/src/annotator.ts index b41971e..deec7b2 100644 --- a/packages/annotator/src/annotator.ts +++ b/packages/annotator/src/annotator.ts @@ -1,5 +1,5 @@ import { type PrecedentAnnotation, PrecedentAnnotationSchema, type Result, ok, err } from '@civic-source/types'; -import { type Logger, createLogger, TokenBucket } from '@civic-source/shared'; +import { type Logger, createLogger } from '@civic-source/shared'; import { type CourtListenerResult, CourtListenerClient } from './client.js'; import { COURTLISTENER_BASE_URL, @@ -7,7 +7,6 @@ import { MAX_HOLDING_SUMMARY_LENGTH, MAX_CASE_NAME_LENGTH, MAX_CITATION_LENGTH, - RATE_LIMIT_PER_HOUR, getApiToken, } from './constants.js'; import { deduplicateCases } from './citation-utils.js'; @@ -155,19 +154,13 @@ export function annotationToYaml(annotation: PrecedentAnnotation): string { export class Annotator { private readonly client: CourtListenerClient; private readonly logger: Logger; - private readonly rateLimiter: TokenBucket; - constructor(options?: { client?: CourtListenerClient; logger?: Logger; rateLimiter?: TokenBucket }) { + constructor(options?: { client?: CourtListenerClient; logger?: Logger }) { this.logger = options?.logger ?? createLogger('Annotator'); this.client = options?.client ?? new CourtListenerClient({ token: getApiToken(), logger: this.logger, }); - this.rateLimiter = options?.rateLimiter ?? new TokenBucket({ - capacity: RATE_LIMIT_PER_HOUR, - refillRate: Math.ceil(RATE_LIMIT_PER_HOUR / 3600), - refillIntervalMs: 1000, - }); } /** Query CourtListener for a section and build a validated PrecedentAnnotation */ @@ -175,12 +168,8 @@ export class Annotator { const timer = this.logger.startTimer('annotateSection'); this.logger.info('Annotating section', { section }); - // Rate limit check - if (!this.rateLimiter.tryConsume()) { - this.logger.warn('Rate limited, waiting for token', { section }); - await this.rateLimiter.waitAndConsume(); - } - + // Rate limiting is enforced by the CourtListener client (the sole HTTP + // choke point); a second bucket here was redundant double-limiting (#230). const searchResult = await this.client.searchByStatute(section); if (!searchResult.ok) { timer(); diff --git a/packages/annotator/src/client.ts b/packages/annotator/src/client.ts index ee8934b..fc5a4d2 100644 --- a/packages/annotator/src/client.ts +++ b/packages/annotator/src/client.ts @@ -3,7 +3,7 @@ import { type Logger, MAX_RETRIES, BASE_BACKOFF_MS, TokenBucket } from '@civic-s import { COURTLISTENER_BASE_URL, SEARCH_ENDPOINT, - RATE_LIMIT_PER_HOUR, + COURTLISTENER_RATE_LIMITER, DEFAULT_PAGE_SIZE, } from './constants.js'; @@ -67,11 +67,7 @@ export class CourtListenerClient { this.token = options.token; this.logger = options.logger; this.pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; - this.rateLimiter = options.rateLimiter ?? new TokenBucket({ - capacity: RATE_LIMIT_PER_HOUR, - refillRate: Math.ceil(RATE_LIMIT_PER_HOUR / 3600), - refillIntervalMs: 1000, - }); + this.rateLimiter = options.rateLimiter ?? new TokenBucket(COURTLISTENER_RATE_LIMITER); } /** diff --git a/packages/annotator/src/constants.ts b/packages/annotator/src/constants.ts index baa2bad..7c6681d 100644 --- a/packages/annotator/src/constants.ts +++ b/packages/annotator/src/constants.ts @@ -9,6 +9,21 @@ export const SEARCH_ENDPOINT = 'search/'; /** CourtListener rate limit: 5000 requests per hour */ export const RATE_LIMIT_PER_HOUR = 5000; +/** + * Token-bucket config for the CourtListener client. + * + * The bucket refills `refillRate` tokens every `refillIntervalMs`, so its + * sustained rate is `refillRate / refillIntervalMs`. To honour exactly + * RATE_LIMIT_PER_HOUR/hour we refill one token every (3,600,000 / limit) ms. + * The previous config (`refillRate: ceil(limit/3600)=2` per 1000 ms) issued + * ~7200/hour — 44% over the documented cap (#230). + */ +export const COURTLISTENER_RATE_LIMITER = { + capacity: RATE_LIMIT_PER_HOUR, + refillRate: 1, + refillIntervalMs: Math.floor(3_600_000 / RATE_LIMIT_PER_HOUR), +} as const; + /** Environment variable name for the API token */ export const API_TOKEN_ENV_VAR = 'COURTLISTENER_API_TOKEN';