OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / lib / auth / rate-limiter.js

95 lines · javascript ref:
/**
 * RateLimiter — Token bucket rate limiting
 *
 * Per-client, per-operation rate limiting with persistent storage.
 */
export class RateLimiter {
  constructor (opts = {}) {
    this.db = opts.db || null
    this.limits = opts.limits || {
      auth: { tokens: 1000, refillRate: 100, refillInterval: 60 * 1000 },
      api: { tokens: 10000, refillRate: 1000, refillInterval: 60 * 1000 }
    }
    this.cacheTimeout = opts.cacheTimeout || 10 * 1000
    this._buckets = new Map()
    this._cleanupInterval = setInterval(() => this._cleanup(), 60 * 1000)
  }

  /**
   * Check rate limit for client/operation
   * @param {string} clientId - Client identifier (IP, user ID, etc.)
   * @param {string} operation - Operation type
   * @returns {Promise<Object>} { allowed, remaining, retryAfter?, resetAt? }
   */
  async check (clientId, operation = 'api') {
    const key = `${clientId}:${operation}`
    const limit = this.limits[operation] || this.limits.api

    let bucket = this._buckets.get(key)
    if (!bucket) {
      bucket = {
        tokens: limit.tokens,
        lastRefill: Date.now(),
        operation
      }
      this._buckets.set(key, bucket)
    }

    // Refill tokens
    const now = Date.now()
    const elapsed = now - bucket.lastRefill
    const refills = Math.floor(elapsed / limit.refillInterval)
    if (refills > 0) {
      bucket.tokens = Math.min(limit.tokens, bucket.tokens + refills * limit.refillRate)
      bucket.lastRefill = now
    }

    // Check
    if (bucket.tokens <= 0) {
      const retryAfter = Math.ceil(limit.refillInterval / 1000)
      return {
        allowed: false,
        remaining: 0,
        retryAfter,
        resetAt: bucket.lastRefill + limit.refillInterval
      }
    }

    // Consume token
    bucket.tokens--

    return {
      allowed: true,
      remaining: bucket.tokens
    }
  }

  /**
   * Cleanup old buckets
   */
  _cleanup () {
    const cutoff = Date.now() - 10 * 60 * 1000 // 10 min
    for (const [key, bucket] of this._buckets) {
      if (bucket.lastRefill < cutoff) {
        this._buckets.delete(key)
      }
    }
  }

  /**
   * Get stats
   */
  async stats () {
    return {
      activeBuckets: this._buckets.size
    }
  }

  destroy () {
    clearInterval(this._cleanupInterval)
    this._buckets.clear()
  }
}

export default RateLimiter