All files / src/utils rateLimiter.ts

97.22% Statements 70/72
95.83% Branches 46/48
100% Functions 11/11
97.1% Lines 67/69

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404                                                                                                                                                                          401x 4x       397x 397x 401x 2x       395x 1x       394x 394x     394x   26x 26x   367x 367x   1x 1x                 287x 12x 3x             347x 347x 347x   347x 65x       65x                                                                                     291x 3x     288x 1x               287x 291x   291x   291x 293x   293x 279x 279x     14x 14x 14x   14x 7x             7x         7x 1x 1x                                                                     13x 2x   11x 1x       10x   10x 9x 9x     1x                                       10x 10x                                     1x                                           33x 33x 33x 33x   33x                                             1x 1x                                                     4x          
/**
 * Rate Limiter utility using token bucket algorithm
 * 
 * **Intelligence Perspective:** Ensures sustainable OSINT collection rates from EP API,
 * preventing service disruption that would compromise intelligence product reliability.
 * 
 * **Business Perspective:** SLA compliance depends on rate limiting—ensures fair resource
 * allocation across API tiers and prevents abuse from high-volume customers.
 * 
 * **Marketing Perspective:** Responsible API usage demonstrates platform maturity
 * and reliability commitment to potential enterprise customers and partners.
 * 
 * ISMS Policy: SC-002 (Secure Coding), AC-003 (Access Control)
 * 
 * Implements token bucket algorithm for rate limiting to prevent abuse
 * and ensure fair resource allocation.
 */
 
/**
 * Rate-limiter status snapshot (used by health checks and monitoring).
 */
export interface RateLimiterStatus {
  /** Number of tokens currently available in the bucket */
  availableTokens: number;
  /** Maximum capacity of the token bucket */
  maxTokens: number;
  /** Percentage of the bucket currently consumed (0–100) */
  utilizationPercent: number;
}
 
/**
 * Result returned by {@link RateLimiter.removeTokens}.
 *
 * Discriminated union: when `allowed` is `true`, tokens were consumed.
 * When `allowed` is `false`, the wait would have exceeded the timeout and
 * `retryAfterMs` is always present with a value ≥ 1 (milliseconds until the
 * bucket is expected to have enough tokens; treat `1` as "retry immediately").
 *
 * **Note:** `remainingTokens` is always a non-negative integer
 * (`Math.floor` of the internal fractional bucket state). This differs from
 * {@link RateLimiter.getAvailableTokens}, which may return a fractional value.
 */
export type RateLimitResult =
  | { allowed: true; remainingTokens: number }
  | { allowed: false; retryAfterMs: number; remainingTokens: number };
 
/**
 * Rate limiter configuration options
 */
interface RateLimiterOptions {
  /**
   * Maximum number of tokens in the bucket (must be a finite integer >= 1)
   */
  tokensPerInterval: number;
  
  /**
   * Time interval for token refill
   */
  interval: 'second' | 'minute' | 'hour';
  
  /**
   * Initial number of tokens (defaults to tokensPerInterval)
   */
  initialTokens?: number;
}
 
/**
 * Public typed configuration for {@link RateLimiter}.
 *
 * Type alias of {@link RateLimiterOptions} so consumers can construct
 * or configure a {@link RateLimiter} using {@link RateLimiterConfig}
 * without any casting.
 */
export type RateLimiterConfig = RateLimiterOptions;
 
/**
 * Token bucket rate limiter implementation
 */
export class RateLimiter {
  private tokens: number;
  private readonly tokensPerInterval: number;
  private readonly intervalMs: number;
  private lastRefill: number;
 
  constructor(options: RateLimiterOptions) {
    if (!Number.isInteger(options.tokensPerInterval) || options.tokensPerInterval < 1) {
      throw new Error(
        `RateLimiter: tokensPerInterval must be a finite integer >= 1, got ${String(options.tokensPerInterval)}`
      );
    }
    this.tokensPerInterval = options.tokensPerInterval;
    const initialTokens = options.initialTokens ?? options.tokensPerInterval;
    if (!Number.isFinite(initialTokens) || initialTokens < 0) {
      throw new Error(
        `RateLimiter: initialTokens must be a finite non-negative number, got ${String(initialTokens)}`
      );
    }
    if (initialTokens > this.tokensPerInterval) {
      throw new Error(
        `RateLimiter: initialTokens (${String(initialTokens)}) must not exceed tokensPerInterval (${String(this.tokensPerInterval)})`
      );
    }
    this.tokens = initialTokens;
    this.lastRefill = Date.now();
    
    // Convert interval to milliseconds
    switch (options.interval) {
      case 'second':
        this.intervalMs = 1000;
        break;
      case 'minute':
        this.intervalMs = 60 * 1000;
        break;
      case 'hour':
        this.intervalMs = 60 * 60 * 1000;
        break;
      default:
        const exhaustive: never = options.interval;
        throw new Error(`Invalid interval: ${String(exhaustive)}`);
    }
  }
 
  /** Coerce an optional timeoutMs value to a safe finite number >= 0. */
  private static resolveTimeout(rawTimeoutMs: number | undefined): number {
    if (rawTimeoutMs === undefined) return 5000;
    if (Number.isFinite(rawTimeoutMs) && rawTimeoutMs >= 0) return rawTimeoutMs;
    return 0;
  }
 
  /**
   * Refill tokens based on elapsed time
   */
  private refill(): void {
    const now = Date.now();
    const elapsedMs = now - this.lastRefill;
    const tokensToAdd = (elapsedMs / this.intervalMs) * this.tokensPerInterval;
    
    if (tokensToAdd > 0) {
      this.tokens = Math.min(
        this.tokensPerInterval,
        this.tokens + tokensToAdd
      );
      this.lastRefill = now;
    }
  }
 
  /**
   * Attempts to consume `count` tokens from the bucket, waiting asynchronously
   * until tokens are available or the timeout expires.
   *
   * Refills the bucket based on elapsed time before each check. If sufficient
   * tokens are available they are consumed immediately. Otherwise the method
   * sleeps until the bucket has enough tokens and retries. If the required wait
   * would exceed `options.timeoutMs` (default **5000 ms**) the call returns
   * immediately with `allowed: false` and a `retryAfterMs` hint. The timeout
   * is enforced as a hard deadline: even if a sleep fires slightly late due to
   * event-loop delay, tokens are never consumed after the deadline has elapsed.
   *
   * @param count - Number of tokens to consume (must be a finite integer ≥ 1 and ≤ `tokensPerInterval`); throws for invalid values
   * @param options.timeoutMs - Maximum time to wait in milliseconds (default 5000); non-finite or negative values are coerced to `0`, meaning the call never blocks and returns `allowed: false` immediately if tokens are unavailable
   * @returns Promise resolving to a {@link RateLimitResult}. `allowed` is `true`
   *   when tokens were consumed, `false` when the timeout was reached.
   *   `remainingTokens` is always a non-negative integer (`Math.floor` of the
   *   internal fractional bucket state); it may differ from
   *   {@link RateLimiter.getAvailableTokens} which returns the raw fractional value.
   *
   * @example
   * ```typescript
   * const result = await rateLimiter.removeTokens(1);
   * if (!result.allowed) {
   *   console.warn(`Rate limited – retry after ${result.retryAfterMs}ms`);
   * } else {
   *   const data = await fetchFromEPAPI('/meps');
   * }
   * ```
   *
   * @security Prevents abusive high-frequency requests to the EP API.
   *   Per ISMS Policy AC-003, rate limiting is a mandatory access control.
   * @since 0.8.0
   */
  async removeTokens(
    count: number,
    options?: { timeoutMs?: number }
  ): Promise<RateLimitResult> {
    // Validate count: must be a finite integer >= 1
    if (!Number.isFinite(count) || count < 1 || !Number.isInteger(count)) {
      throw new Error(`removeTokens: count must be a finite integer >= 1, got ${String(count)}`);
    }
    // A count larger than the bucket capacity can never be satisfied
    if (count > this.tokensPerInterval) {
      throw new Error(
        `removeTokens: count (${String(count)}) exceeds bucket capacity (${String(this.tokensPerInterval)})`
      );
    }
 
    // Validate timeoutMs: coerce invalid (NaN/Infinity/negative) to 0 so the
    // call never blocks and either succeeds immediately if enough tokens are
    // available or returns allowed:false immediately if not
    const rawTimeoutMs = options?.timeoutMs;
    const timeoutMs = RateLimiter.resolveTimeout(rawTimeoutMs);
 
    const deadline = Date.now() + timeoutMs;
 
    for (;;) {
      this.refill();
 
      if (this.tokens >= count) {
        this.tokens -= count;
        return { allowed: true, remainingTokens: Math.floor(this.tokens) };
      }
 
      const tokensNeeded = count - this.tokens;
      const waitMs = Math.ceil((tokensNeeded / this.tokensPerInterval) * this.intervalMs);
      const remainingMs = deadline - Date.now();
 
      if (waitMs > remainingMs) {
        return {
          allowed: false,
          retryAfterMs: waitMs,
          remainingTokens: Math.floor(this.tokens),
        };
      }
 
      await new Promise<void>(resolve => setTimeout(resolve, waitMs));
 
      // Hard deadline guard: if the timer fired late (event-loop delay) and the
      // deadline has already elapsed, reject without consuming tokens.
      // retryAfterMs is always >= 1 so callers always receive a positive retry hint.
      if (Date.now() >= deadline) {
        this.refill();
        return {
          allowed: false,
          retryAfterMs: Math.max(1, Math.ceil(((count - this.tokens) / this.tokensPerInterval) * this.intervalMs)),
          remainingTokens: Math.floor(this.tokens),
        };
      }
    }
  }
 
  /**
   * Attempts to consume `count` tokens without throwing on failure.
   *
   * Synchronous alternative to {@link removeTokens} that returns `false`
   * instead of waiting when the bucket lacks tokens. Useful in hot paths
   * where callers want to branch on availability rather than await a refill.
   *
   * **Note:** This method still throws for invalid `count` arguments (non-integer,
   * `< 1`, or exceeding bucket capacity). It only avoids throwing when there are
   * insufficient tokens in the bucket at the time of the call.
   *
   * @param count - Number of tokens to consume (must be a finite integer ≥ 1 and ≤ `tokensPerInterval`); throws for invalid values
   * @returns `true` if tokens were successfully consumed, `false` if the
   *   bucket did not have enough tokens (bucket is left unchanged)
   *
   * @example
   * ```typescript
   * if (!rateLimiter.tryRemoveTokens(1)) {
   *   return { error: 'Rate limit exceeded. Please try again later.' };
   * }
   * const data = await fetchFromEPAPI('/meps');
   * ```
   *
   * @since 0.8.0
   */
  tryRemoveTokens(count: number): boolean {
    if (!Number.isFinite(count) || count < 1 || !Number.isInteger(count)) {
      throw new Error(`tryRemoveTokens: count must be a finite integer >= 1, got ${String(count)}`);
    }
    if (count > this.tokensPerInterval) {
      throw new Error(
        `tryRemoveTokens: count (${String(count)}) exceeds bucket capacity (${String(this.tokensPerInterval)})`
      );
    }
    this.refill();
    
    if (this.tokens >= count) {
      this.tokens -= count;
      return true;
    }
    
    return false;
  }
 
  /**
   * Returns the number of tokens currently available in the bucket.
   *
   * Triggers a refill calculation based on elapsed time before returning
   * the value, so the result reflects the current real-time availability.
   *
   * @returns Current token count (may be fractional; floor before display)
   *
   * @example
   * ```typescript
   * const tokens = rateLimiter.getAvailableTokens();
   * console.log(`${tokens} / ${rateLimiter.getMaxTokens()} tokens available`);
   * ```
   *
   * @since 0.8.0
   */
  getAvailableTokens(): number {
    this.refill();
    return this.tokens;
  }
 
  /**
   * Returns the maximum token capacity of this bucket.
   *
   * Equal to the `tokensPerInterval` value passed at construction time.
   * Does **not** trigger a refill calculation.
   *
   * @returns Maximum number of tokens the bucket can hold
   *
   * @example
   * ```typescript
   * const max = rateLimiter.getMaxTokens(); // e.g. 100
   * ```
   *
   * @since 0.8.0
   */
  getMaxTokens(): number {
    return this.tokensPerInterval;
  }
 
  /**
   * Returns a typed status snapshot for health checks and monitoring.
   *
   * Triggers a refill calculation so the snapshot reflects real-time bucket
   * state. Useful for `/health` endpoints and Prometheus exporters.
   *
   * @returns Current {@link RateLimiterStatus} snapshot with `availableTokens`,
   *   `maxTokens`, and `utilizationPercent` (0–100)
   *
   * @example
   * ```typescript
   * const status = rateLimiter.getStatus();
   * console.log(`${status.utilizationPercent}% utilized`);
   * // e.g. "45% utilized"
   * ```
   *
   * @since 0.8.0
   */
  getStatus(): RateLimiterStatus {
    this.refill();
    const available = this.tokens;
    const max = this.tokensPerInterval;
    const utilization = max > 0 ? Math.round(((max - available) / max) * 100) : 0;
 
    return {
      availableTokens: Math.floor(available),
      maxTokens: max,
      utilizationPercent: utilization,
    };
  }
 
  /**
   * Resets the bucket to full capacity and clears the refill timer.
   *
   * Useful in tests or after a planned maintenance window where queued
   * demand should not be penalised by an already-depleted bucket.
   *
   * @example
   * ```typescript
   * afterEach(() => {
   *   rateLimiter.reset();
   * });
   * ```
   *
   * @since 0.8.0
   */
  reset(): void {
    this.tokens = this.tokensPerInterval;
    this.lastRefill = Date.now();
  }
}
 
/**
 * Creates a {@link RateLimiter} pre-configured for EP API usage.
 *
 * Default configuration: **100 tokens per minute** — aligned with the
 * European Parliament Open Data Portal's recommended fair-use policy.
 *
 * @returns A new {@link RateLimiter} instance with standard EP API settings
 *
 * @example
 * ```typescript
 * const rateLimiter = createStandardRateLimiter();
 * const result = await rateLimiter.removeTokens(1);
 * if (result.allowed) {
 *   const data = await fetchFromEPAPI('/meps');
 * }
 * ```
 *
 * @security Ensures sustainable OSINT collection rates from the EP API and
 *   prevents service disruption. Per ISMS Policy AC-003, rate limiting is a
 *   mandatory access control for external API calls.
 * @since 0.8.0
 */
export function createStandardRateLimiter(): RateLimiter {
  return new RateLimiter({
    tokensPerInterval: 100,
    interval: 'minute'
  });
}