# All files / src/utils rateLimiter.ts

97.36% Statements 74/76
96.42% Branches 54/56
100% Functions 11/11
97.26% Lines 71/73

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

    476x
    4x

    472x
    472x
    476x
    2x

    470x
    1x

    469x
    469x

    469x

    26x
    26x

    442x
    442x

    1x
    1x

    358x
    12x
    3x

    423x
    423x
    423x

    423x
    93x

    93x

    362x
    3x

    359x
    1x

    358x
    362x

    362x

    362x
    364x

    364x
    350x
    350x

    14x
    14x
    14x

    14x
    7x

    7x

    7x
    1x
    1x

    13x
    2x

    11x
    1x

    10x

    10x
    9x
    9x

    1x

    15x
    15x

    1x

    33x
    33x
    33x
    33x

    33x

    1x
    1x

    25x
    25x
    25x

    25x
    25x

     /**
     * 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();

        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> {
        if (!Number.isFinite(count) || count < 1 || !Number.isInteger(count)) {
          throw new Error(`removeTokens: count must be a finite integer >= 1, got ${String(count)}`);
        }
        if (count > this.tokensPerInterval) {
          throw new Error(
            `removeTokens: count (${String(count)}) exceeds bucket capacity (${String(this.tokensPerInterval)})`
          );
        }

        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));

          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
     * The token capacity is configurable via the `EP_RATE_LIMIT` environment
     * variable (must be a positive integer). When the variable is absent, empty,
     * non-numeric, zero, or negative, the factory falls back to 100 tokens/min.
     *
     * ```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 {
      const DEFAULT_TOKENS = 100;
      const rawEnvValue = (process.env['EP_RATE_LIMIT'] ?? '').trim();
      const parsedEnvValue = /^[1-9]\d*$/.test(rawEnvValue) ? Number(rawEnvValue) : Number.NaN;
      const tokensPerInterval =
        Number.isInteger(parsedEnvValue) && parsedEnvValue > 0 ? parsedEnvValue : DEFAULT_TOKENS;
      return new RateLimiter({
        tokensPerInterval,
        interval: 'minute'
      });
    }