Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/annotator/src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
19 changes: 4 additions & 15 deletions packages/annotator/src/annotator.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
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,
COURT_PRIORITY,
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';
Expand Down Expand Up @@ -155,32 +154,22 @@ 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 */
async annotateSection(section: string): Promise<Result<AnnotationResult>> {
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();
Expand Down
8 changes: 2 additions & 6 deletions packages/annotator/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
}

/**
Expand Down
15 changes: 15 additions & 0 deletions packages/annotator/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Loading